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";
17pub const DEFAULT_GEN_ORB_MCP_ORB_VERSION: &str = "0.1.48";
21
22pub(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#[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 push_ssh_fingerprint: push_ssh_fingerprint
89 .map(str::trim)
90 .unwrap_or("")
91 .to_string(),
92 contexts,
93 }))
94}
95
96pub(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
113pub(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
137pub(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#[derive(Debug, clap::Args)]
155pub struct Init {
156 #[arg(long)]
158 pub binary: String,
159
160 #[arg(long = "public-orb-namespace")]
163 pub public_orb_namespaces: Vec<String>,
164
165 #[arg(long = "private-orb-namespace")]
169 pub private_orb_namespaces: Vec<String>,
170
171 #[arg(long)]
173 pub build_workflow: String,
174
175 #[arg(long)]
177 pub release_workflow: String,
178
179 #[arg(long)]
181 pub requires_job: Option<String>,
182
183 #[arg(long)]
187 pub crate_tag_prefix: String,
188
189 #[arg(long)]
194 pub release_after_job: String,
195
196 #[arg(long, default_value = "orb")]
198 pub orb_dir: String,
199
200 #[arg(long, default_value = ".circleci")]
202 pub ci_dir: PathBuf,
203
204 #[arg(long, default_value = "12.3.3")]
206 pub orb_tools_version: String,
207
208 #[arg(long, default_value = DEFAULT_DOCKER_ORB_VERSION)]
210 pub docker_orb_version: String,
211
212 #[arg(long)]
214 pub docker_namespace: String,
215
216 #[arg(long)]
219 pub docker_context: Option<String>,
220
221 #[arg(long)]
224 pub orb_context: Option<String>,
225
226 #[arg(long, default_value = env!("CARGO_PKG_VERSION"))]
229 pub gen_circleci_orb_version: String,
230
231 #[arg(long)]
233 pub mcp: bool,
234
235 #[arg(long)]
239 pub mcp_earliest_version: Option<String>,
240
241 #[arg(long = "mcp-context", value_delimiter = ',')]
246 pub mcp_context: Vec<String>,
247
248 #[arg(long, value_delimiter = ',')]
251 pub git_push_subcommands: Vec<String>,
252
253 #[arg(long)]
255 pub home_url: Option<String>,
256
257 #[arg(long)]
259 pub source_url: Option<String>,
260
261 #[arg(long)]
267 pub record: bool,
268
269 #[arg(long)]
271 pub record_gpg_key_env: Option<String>,
272
273 #[arg(long)]
275 pub record_gpg_trust_env: Option<String>,
276
277 #[arg(long)]
279 pub record_user_name_env: Option<String>,
280
281 #[arg(long)]
283 pub record_user_email_env: Option<String>,
284
285 #[arg(long)]
287 pub record_signing_key_env: Option<String>,
288
289 #[arg(long)]
295 pub record_push_ssh_fingerprint: Option<String>,
296
297 #[arg(long = "record-context", value_delimiter = ',')]
301 pub record_contexts: Vec<String>,
302
303 #[arg(long)]
305 pub dry_run: bool,
306}
307
308pub(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 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, orbs: None,
367 subcommand,
368 job_group: None,
369 extra_job: None,
370 record: None, }
372}
373
374impl Init {
375 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 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 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 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 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 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 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 no_record: true,
764 check: false,
766 };
767 gen.run()?;
768
769 let opts = ci_patcher::PatchOpts {
771 binary: self.binary.clone(),
772 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 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 gen_orb_mcp_orb_version: None,
838 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 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 #[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 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 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 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 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 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 #[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); 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 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 #[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, 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 &[], )
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); 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 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 #[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 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 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); 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 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(); 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 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 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}