1use crate::config::{DiscordConfig, DiscordEventsConfig, LinearConfig, LinearReleaseConfig};
8use crate::strategies::deployment_config::{
9 DeployProjectConfig, GitHubProjectConfig, GitHubReleaseBranchConfig, KubernetesProjectConfig,
10 OciProjectConfig, OciRegistryConfig, PublishDistConfig, PublishTargetConfig, WorkerConfig,
11 WorkerDeployConfig, XbpConfig, DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE,
12};
13use crate::utils::{
14 collapse_project_path, default_project_yaml_config_path, serialize_xbp_yaml,
15};
16use colored::Colorize;
17use dialoguer::{theme::ColorfulTheme, Confirm, Input, MultiSelect, Select};
18use std::collections::HashMap;
19use std::fs;
20use std::path::{Path, PathBuf};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum InitSetupModule {
25 VersionTargets,
26 GitHubRelease,
27 LinearRelease,
28 CargoDist,
29 PublishCrates,
30 PublishNpm,
31 CloudflareWorkers,
32 OciRegistry,
33 Kubernetes,
34 DeployDefaults,
35 Discord,
36 Monitoring,
37}
38
39impl InitSetupModule {
40 pub fn all() -> &'static [InitSetupModule] {
41 &[
42 Self::VersionTargets,
43 Self::GitHubRelease,
44 Self::LinearRelease,
45 Self::CargoDist,
46 Self::PublishCrates,
47 Self::PublishNpm,
48 Self::CloudflareWorkers,
49 Self::OciRegistry,
50 Self::Kubernetes,
51 Self::DeployDefaults,
52 Self::Discord,
53 Self::Monitoring,
54 ]
55 }
56
57 pub fn label(self) -> &'static str {
58 match self {
59 Self::VersionTargets => "Version targets & release scope",
60 Self::GitHubRelease => "GitHub release branch + auto-push",
61 Self::LinearRelease => "Linear release posts (credentials + initiative)",
62 Self::CargoDist => "cargo-dist / dist packaging for GitHub releases",
63 Self::PublishCrates => "crates.io publish target",
64 Self::PublishNpm => "npm publish target",
65 Self::CloudflareWorkers => "Cloudflare Workers apps",
66 Self::OciRegistry => "OCI registry credentials (GHCR / etc.)",
67 Self::Kubernetes => "Kubernetes defaults (context / namespace)",
68 Self::DeployDefaults => "Deploy groups & default environment",
69 Self::Discord => "Discord webhook notifications",
70 Self::Monitoring => "Synthetic monitor URL",
71 }
72 }
73
74 pub fn default_selected(self) -> bool {
75 matches!(
76 self,
77 Self::VersionTargets
78 | Self::GitHubRelease
79 | Self::LinearRelease
80 | Self::CargoDist
81 | Self::PublishCrates
82 | Self::Discord
83 | Self::DeployDefaults
84 )
85 }
86}
87
88pub fn default_modules_for_project(
90 has_cargo_toml: bool,
91 has_package_json: bool,
92 has_wrangler: bool,
93 has_dockerfile: bool,
94 has_k8s_manifests: bool,
95) -> Vec<InitSetupModule> {
96 let mut selected = vec![
97 InitSetupModule::VersionTargets,
98 InitSetupModule::GitHubRelease,
99 InitSetupModule::LinearRelease,
100 InitSetupModule::Discord,
101 ];
102 if has_cargo_toml {
103 selected.push(InitSetupModule::CargoDist);
104 selected.push(InitSetupModule::PublishCrates);
105 }
106 if has_package_json {
107 selected.push(InitSetupModule::PublishNpm);
108 }
109 if has_wrangler {
110 selected.push(InitSetupModule::CloudflareWorkers);
111 }
112 if has_dockerfile {
113 selected.push(InitSetupModule::OciRegistry);
114 }
115 if has_k8s_manifests {
116 selected.push(InitSetupModule::Kubernetes);
117 }
118 selected.push(InitSetupModule::DeployDefaults);
119 selected
120}
121
122pub fn build_github_release_config(
124 repository: Option<String>,
125 auto_push_on_commit: bool,
126 release_branch_enabled: bool,
127 naming_template: Option<String>,
128) -> GitHubProjectConfig {
129 let template = naming_template
130 .map(|t| t.trim().to_string())
131 .filter(|t| !t.is_empty());
132 GitHubProjectConfig {
133 repository: repository
134 .map(|r| r.trim().to_string())
135 .filter(|r| !r.is_empty()),
136 auto_push_on_commit,
137 release_branch: if release_branch_enabled {
138 Some(GitHubReleaseBranchConfig {
139 enabled: true,
140 naming_template: template.or_else(|| {
141 Some(DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE.to_string())
142 }),
143 })
144 } else {
145 None
146 },
147 runners: None,
148 }
149}
150
151pub fn build_linear_release_config(
153 enabled: bool,
154 initiative_ids: Vec<String>,
155 organization_name: Option<String>,
156) -> LinearConfig {
157 let org = organization_name
158 .map(|s| s.trim().to_string())
159 .filter(|s| !s.is_empty());
160 LinearConfig {
161 release: Some(LinearReleaseConfig {
162 enabled: Some(enabled),
163 initiative_ids: if initiative_ids.is_empty() {
164 None
165 } else {
166 Some(initiative_ids)
167 },
168 organization_name: org.clone(),
169 health: None,
170 }),
171 default_team_id: None,
172 default_team_key: None,
173 organization_name: org,
174 }
175}
176
177pub fn detect_project_markers(project_root: &Path) -> ProjectMarkers {
179 ProjectMarkers {
180 has_cargo_toml: project_root.join("Cargo.toml").exists()
181 || project_root.join("crates").exists(),
182 has_package_json: project_root.join("package.json").exists()
183 || project_root.join("apps").exists()
184 || project_root.join("packages").exists(),
185 has_wrangler: find_wrangler_config(project_root).is_some(),
186 has_dockerfile: project_root.join("Dockerfile").exists()
187 || project_root.join("docker-compose.yml").exists()
188 || project_root.join("compose.yml").exists(),
189 has_k8s_manifests: project_root.join("k8s").exists()
190 || project_root.join("deploy/k8s").exists()
191 || project_root.join("charts").exists(),
192 has_dist_workspace: project_root.join("dist-workspace.toml").exists(),
193 }
194}
195
196#[derive(Debug, Clone, Default)]
197pub struct ProjectMarkers {
198 pub has_cargo_toml: bool,
199 pub has_package_json: bool,
200 pub has_wrangler: bool,
201 pub has_dockerfile: bool,
202 pub has_k8s_manifests: bool,
203 pub has_dist_workspace: bool,
204}
205
206fn find_wrangler_config(project_root: &Path) -> Option<PathBuf> {
207 let candidates = [
208 project_root.join("wrangler.toml"),
209 project_root.join("wrangler.jsonc"),
210 project_root.join("wrangler.json"),
211 project_root.join("apps/web/wrangler.jsonc"),
212 project_root.join("apps/web/wrangler.toml"),
213 project_root.join("apps/docs/wrangler.jsonc"),
214 project_root.join("apps/api/wrangler.jsonc"),
215 ];
216 candidates.into_iter().find(|p| p.exists())
217}
218
219pub async fn run_init_setup_wizard(
221 project_root: &Path,
222 config: &mut XbpConfig,
223) -> Result<Vec<String>, String> {
224 let theme = ColorfulTheme::default();
225 println!();
226 println!(
227 "{} {}",
228 "XBP init".bright_magenta().bold(),
229 "— full project setup wizard".bright_white()
230 );
231 println!(
232 "{}",
233 "Walk through release, publish, Workers, OCI/K8s, Discord, and more. Skip any step."
234 .bright_black()
235 );
236 println!();
237
238 let proceed = Confirm::with_theme(&theme)
239 .with_prompt("Run the full interactive setup wizard now?")
240 .default(true)
241 .interact()
242 .map_err(|e| e.to_string())?;
243 if !proceed {
244 println!(
245 "{}",
246 "Skipped full wizard. Re-run later with `xbp init` or configure modules via `xbp config …`."
247 .dimmed()
248 );
249 return Ok(Vec::new());
250 }
251
252 let markers = detect_project_markers(project_root);
253 let defaults = default_modules_for_project(
254 markers.has_cargo_toml || markers.has_dist_workspace,
255 markers.has_package_json,
256 markers.has_wrangler,
257 markers.has_dockerfile,
258 markers.has_k8s_manifests,
259 );
260 let modules = InitSetupModule::all();
261 let default_flags: Vec<bool> = modules
262 .iter()
263 .map(|m| defaults.contains(m) || m.default_selected())
264 .collect();
265
266 let labels: Vec<&str> = modules.iter().map(|m| m.label()).collect();
267 let chosen = MultiSelect::with_theme(&theme)
268 .with_prompt("Select setup modules (space to toggle, enter to confirm)")
269 .items(&labels)
270 .defaults(&default_flags)
271 .interact()
272 .map_err(|e| e.to_string())?;
273
274 let selected: Vec<InitSetupModule> = chosen.into_iter().map(|i| modules[i]).collect();
275 if selected.is_empty() {
276 println!("{}", "No modules selected.".yellow());
277 return Ok(Vec::new());
278 }
279
280 let mut notes = Vec::new();
281 for module in selected {
282 println!();
283 println!(
284 "{} {}",
285 "▸".bright_cyan().bold(),
286 module.label().bright_white().bold()
287 );
288 match module {
289 InitSetupModule::VersionTargets => {
290 setup_version_targets(project_root, config, &theme, &mut notes)?;
291 }
292 InitSetupModule::GitHubRelease => {
293 setup_github_release(project_root, config, &theme, &mut notes).await?;
294 }
295 InitSetupModule::LinearRelease => {
296 setup_linear_release(config, &theme, &mut notes).await?;
297 }
298 InitSetupModule::CargoDist => {
299 setup_cargo_dist(config, &markers, &theme, &mut notes)?;
300 }
301 InitSetupModule::PublishCrates => {
302 setup_publish_target(config, "crates", project_root, &theme, &mut notes)?;
303 }
304 InitSetupModule::PublishNpm => {
305 setup_publish_target(config, "npm", project_root, &theme, &mut notes)?;
306 }
307 InitSetupModule::CloudflareWorkers => {
308 setup_cloudflare_workers(project_root, config, &theme, &mut notes)?;
309 }
310 InitSetupModule::OciRegistry => {
311 setup_oci(config, &theme, &mut notes)?;
312 }
313 InitSetupModule::Kubernetes => {
314 setup_kubernetes(config, &theme, &mut notes)?;
315 }
316 InitSetupModule::DeployDefaults => {
317 setup_deploy_defaults(config, &theme, &mut notes)?;
318 }
319 InitSetupModule::Discord => {
320 setup_discord(config, &theme, &mut notes)?;
321 }
322 InitSetupModule::Monitoring => {
323 setup_monitoring(config, &theme, &mut notes)?;
324 }
325 }
326 }
327
328 let yaml_path = default_project_yaml_config_path(project_root);
329 if let Some(parent) = yaml_path.parent() {
330 fs::create_dir_all(parent).map_err(|e| format!("Failed to create {}: {e}", parent.display()))?;
331 }
332 let yaml = serialize_xbp_yaml(config).map_err(|e| format!("Serialize config: {e}"))?;
333 fs::write(&yaml_path, yaml).map_err(|e| format!("Write {}: {e}", yaml_path.display()))?;
334 notes.push(format!("Updated {}", yaml_path.display()));
335
336 println!();
337 println!(
338 "{} {}",
339 "Done.".green().bold(),
340 "Project setup modules applied.".bright_white()
341 );
342 println!("{}", "Next steps:".bright_cyan().bold());
343 println!(" {} validate host readiness", "xbp diag --full".dimmed());
344 println!(" {} bump & release", "xbp version release".dimmed());
345 println!(" {} plan deploys", "xbp deploy all --plan".dimmed());
346 println!(
347 " {} Discord test",
348 "xbp config discord test".dimmed()
349 );
350 if config.workers.as_ref().map(|w| !w.is_empty()).unwrap_or(false) {
351 println!(
352 " {} Workers",
353 "xbp workers list / xbp workers deploy".dimmed()
354 );
355 }
356
357 Ok(vec![yaml_path.display().to_string()])
358}
359
360fn setup_version_targets(
361 project_root: &Path,
362 config: &mut XbpConfig,
363 theme: &ColorfulTheme,
364 notes: &mut Vec<String>,
365) -> Result<(), String> {
366 let discovered = crate::commands::project_services::discover_service_version_targets_for_project(
367 project_root,
368 project_root,
369 &config.ignore_paths,
370 );
371 if !discovered.is_empty() {
372 println!(
373 " Discovered {} version target(s):",
374 discovered.len()
375 );
376 for t in discovered.iter().take(12) {
377 println!(" • {}", t.dimmed());
378 }
379 if discovered.len() > 12 {
380 println!(" … and {} more", discovered.len() - 12);
381 }
382 }
383
384 let use_discovered = if discovered.is_empty() {
385 false
386 } else {
387 Confirm::with_theme(theme)
388 .with_prompt("Use discovered version targets?")
389 .default(true)
390 .interact()
391 .map_err(|e| e.to_string())?
392 };
393
394 if use_discovered {
395 for t in discovered {
396 if !config.version_targets.iter().any(|e| e == &t) {
397 config.version_targets.push(t);
398 }
399 }
400 config.version_targets.sort();
401 notes.push(format!(
402 "version_targets ← {} path(s)",
403 config.version_targets.len()
404 ));
405 } else {
406 let extra: String = Input::with_theme(theme)
407 .with_prompt("Extra version targets (comma-separated paths, empty to skip)")
408 .allow_empty(true)
409 .interact_text()
410 .map_err(|e| e.to_string())?;
411 for part in extra.split(',') {
412 let t = part.trim();
413 if !t.is_empty() && !config.version_targets.iter().any(|e| e == t) {
414 config.version_targets.push(t.to_string());
415 }
416 }
417 }
418
419 let enable_release = Confirm::with_theme(theme)
420 .with_prompt("Enable repository-wide releases (`xbp version release`)?")
421 .default(true)
422 .interact()
423 .map_err(|e| e.to_string())?;
424 if !enable_release {
425 if let Some(name) = config.services.as_ref().and_then(|s| s.first()).map(|s| s.name.clone()) {
426 if !config.release_disabled.iter().any(|x| x == &name) {
427 config.release_disabled.push(name);
428 }
429 }
430 notes.push("release disabled for default service (adjust release_disabled as needed)".into());
431 } else {
432 notes.push("releases enabled — use `xbp version release`".into());
433 }
434 Ok(())
435}
436
437fn setup_cargo_dist(
438 config: &mut XbpConfig,
439 markers: &ProjectMarkers,
440 theme: &ColorfulTheme,
441 notes: &mut Vec<String>,
442) -> Result<(), String> {
443 let enable = Confirm::with_theme(theme)
444 .with_prompt(format!(
445 "Enable cargo-dist packaging on release?{}",
446 if markers.has_dist_workspace {
447 " (dist-workspace.toml detected)"
448 } else {
449 ""
450 }
451 ))
452 .default(markers.has_dist_workspace || markers.has_cargo_toml)
453 .interact()
454 .map_err(|e| e.to_string())?;
455 if !enable {
456 notes.push("cargo-dist left disabled".into());
457 return Ok(());
458 }
459
460 let installers_raw: String = Input::with_theme(theme)
461 .with_prompt("Installers (comma-separated: shell, msi, powershell, npm)")
462 .with_initial_text("shell,msi")
463 .interact_text()
464 .map_err(|e| e.to_string())?;
465 let installers = split_csv(&installers_raw);
466 let generate_ci = Confirm::with_theme(theme)
467 .with_prompt("Generate/update cargo-dist GitHub Actions CI?")
468 .default(true)
469 .interact()
470 .map_err(|e| e.to_string())?;
471
472 let mut publish = config.publish.clone().unwrap_or_default();
473 publish.dist = Some(PublishDistConfig {
474 enabled: Some(true),
475 packages: Vec::new(),
476 installers,
477 targets: Vec::new(),
478 artifacts_modes: vec!["host".into(), "global".into()],
479 allow_dirty: Some(true),
480 generate_ci: Some(generate_ci),
481 tool: None,
482 });
483 config.publish = Some(publish);
484 notes.push("publish.dist enabled for version release assets".into());
485 Ok(())
486}
487
488fn setup_publish_target(
489 config: &mut XbpConfig,
490 provider: &str,
491 project_root: &Path,
492 theme: &ColorfulTheme,
493 notes: &mut Vec<String>,
494) -> Result<(), String> {
495 let enable = Confirm::with_theme(theme)
496 .with_prompt(format!("Configure {provider} publish target?"))
497 .default(true)
498 .interact()
499 .map_err(|e| e.to_string())?;
500 if !enable {
501 return Ok(());
502 }
503
504 let default_pkg = config.project_name.clone();
505 let default_manifest = if provider == "crates" {
506 if project_root.join("Cargo.toml").exists() {
507 "Cargo.toml"
508 } else {
509 "crates/cli/Cargo.toml"
510 }
511 } else if project_root.join("package.json").exists() {
512 "package.json"
513 } else {
514 "apps/web/package.json"
515 };
516
517 let package_name: String = Input::with_theme(theme)
518 .with_prompt(format!("{provider} package name"))
519 .with_initial_text(default_pkg)
520 .interact_text()
521 .map_err(|e| e.to_string())?;
522 let manifest_path: String = Input::with_theme(theme)
523 .with_prompt("Manifest path (repo-relative)")
524 .with_initial_text(default_manifest)
525 .interact_text()
526 .map_err(|e| e.to_string())?;
527 let working_directory: String = Input::with_theme(theme)
528 .with_prompt("Working directory (repo-relative)")
529 .with_initial_text(".")
530 .interact_text()
531 .map_err(|e| e.to_string())?;
532 let token_default = if provider == "crates" {
533 "${CARGO_REGISTRY_TOKEN}"
534 } else {
535 "${NPM_TOKEN}"
536 };
537 let token: String = Input::with_theme(theme)
538 .with_prompt("Token env placeholder")
539 .with_initial_text(token_default)
540 .interact_text()
541 .map_err(|e| e.to_string())?;
542 let publish_command = if provider == "crates" {
543 "cargo publish --locked"
544 } else {
545 "npm publish --access public"
546 };
547
548 let target = PublishTargetConfig {
549 enabled: Some(true),
550 package_name: Some(package_name.trim().to_string()),
551 working_directory: Some(working_directory.trim().to_string()),
552 manifest_path: Some(manifest_path.trim().to_string()),
553 token: Some(token.trim().to_string()),
554 preflight_commands: Vec::new(),
555 publish_command: Some(publish_command.into()),
556 use_wsl: Some(false),
557 wsl_distribution: None,
558 generate_npmrc: if provider == "npm" { Some(true) } else { None },
559 access: if provider == "npm" {
560 Some("public".into())
561 } else {
562 None
563 },
564 ..Default::default()
565 };
566
567 let mut publish = config.publish.clone().unwrap_or_default();
568 if provider == "crates" {
569 publish.crates = Some(target);
570 } else {
571 publish.npm = Some(target);
572 }
573 config.publish = Some(publish);
574 notes.push(format!("publish.{provider} configured"));
575 Ok(())
576}
577
578fn setup_cloudflare_workers(
579 project_root: &Path,
580 config: &mut XbpConfig,
581 theme: &ColorfulTheme,
582 notes: &mut Vec<String>,
583) -> Result<(), String> {
584 let enable = Confirm::with_theme(theme)
585 .with_prompt("Register Cloudflare Workers apps in xbp.yaml?")
586 .default(true)
587 .interact()
588 .map_err(|e| e.to_string())?;
589 if !enable {
590 return Ok(());
591 }
592
593 let mut workers = config.workers.clone().unwrap_or_default();
594 let discovered = discover_worker_roots(project_root);
595 if !discovered.is_empty() {
596 println!(" Found Worker-like roots:");
597 for d in &discovered {
598 println!(" • {}", d.display().to_string().dimmed());
599 }
600 let use_all = Confirm::with_theme(theme)
601 .with_prompt("Register all discovered Worker roots?")
602 .default(true)
603 .interact()
604 .map_err(|e| e.to_string())?;
605 if use_all {
606 for root in discovered {
607 let name = root
608 .file_name()
609 .and_then(|s| s.to_str())
610 .unwrap_or("worker")
611 .to_string();
612 let rel = collapse_project_path(project_root, &root.to_string_lossy());
613 if workers.iter().any(|w| w.root == rel || w.name == name) {
614 continue;
615 }
616 workers.push(WorkerConfig {
617 name: name.clone(),
618 root: rel,
619 script_name: Some(name),
620 service: None,
621 deploy: Some(WorkerDeployConfig {
622 ..Default::default()
623 }),
624 container: None,
625 containers: None,
626 durable_objects: None,
627 });
628 }
629 }
630 }
631
632 let add_manual = Confirm::with_theme(theme)
633 .with_prompt("Add a Worker manually?")
634 .default(workers.is_empty())
635 .interact()
636 .map_err(|e| e.to_string())?;
637 if add_manual {
638 let name: String = Input::with_theme(theme)
639 .with_prompt("Worker name")
640 .with_initial_text("web")
641 .interact_text()
642 .map_err(|e| e.to_string())?;
643 let root: String = Input::with_theme(theme)
644 .with_prompt("Worker root (repo-relative)")
645 .with_initial_text("apps/web")
646 .interact_text()
647 .map_err(|e| e.to_string())?;
648 workers.push(WorkerConfig {
649 name: name.clone(),
650 root: root.trim().to_string(),
651 script_name: Some(name),
652 service: None,
653 deploy: Some(WorkerDeployConfig::default()),
654 container: None,
655 containers: None,
656 durable_objects: None,
657 });
658 }
659
660 if workers.is_empty() {
661 notes.push("no workers registered — run `xbp workers deploy configure` later".into());
662 } else {
663 config.workers = Some(workers);
664 notes.push("workers[] registered in xbp.yaml".into());
665 println!(
666 " {}",
667 "Tip: configure credentials with `xbp config cloudflare setup`".dimmed()
668 );
669 }
670 Ok(())
671}
672
673fn discover_worker_roots(project_root: &Path) -> Vec<PathBuf> {
674 let mut out = Vec::new();
675 let apps = project_root.join("apps");
676 if apps.is_dir() {
677 if let Ok(rd) = fs::read_dir(&apps) {
678 for entry in rd.flatten() {
679 let path = entry.path();
680 if !path.is_dir() {
681 continue;
682 }
683 let has_wrangler = ["wrangler.toml", "wrangler.jsonc", "wrangler.json"]
684 .iter()
685 .any(|f| path.join(f).exists());
686 if has_wrangler {
687 out.push(path);
688 }
689 }
690 }
691 }
692 if project_root.join("wrangler.toml").exists() || project_root.join("wrangler.jsonc").exists() {
693 out.push(project_root.to_path_buf());
694 }
695 out.sort();
696 out.dedup();
697 out
698}
699
700fn setup_oci(
701 config: &mut XbpConfig,
702 theme: &ColorfulTheme,
703 notes: &mut Vec<String>,
704) -> Result<(), String> {
705 let enable = Confirm::with_theme(theme)
706 .with_prompt("Configure an OCI registry (e.g. ghcr.io)?")
707 .default(true)
708 .interact()
709 .map_err(|e| e.to_string())?;
710 if !enable {
711 return Ok(());
712 }
713 let registry: String = Input::with_theme(theme)
714 .with_prompt("Registry host")
715 .with_initial_text("ghcr.io")
716 .interact_text()
717 .map_err(|e| e.to_string())?;
718 let username: String = Input::with_theme(theme)
719 .with_prompt("Registry username (or GitHub user/org)")
720 .allow_empty(true)
721 .interact_text()
722 .map_err(|e| e.to_string())?;
723 let token_source: String = Input::with_theme(theme)
724 .with_prompt("Token env placeholder")
725 .with_initial_text("${GITHUB_TOKEN}")
726 .interact_text()
727 .map_err(|e| e.to_string())?;
728
729 let mut registries = HashMap::new();
730 registries.insert(
731 registry.trim().to_string(),
732 OciRegistryConfig {
733 provider: Some("github".into()),
734 username: Some(username.trim().to_string()).filter(|s| !s.is_empty()),
735 token_source: Some(token_source.trim().to_string()),
736 url: None,
737 },
738 );
739 config.oci = Some(OciProjectConfig {
740 registries,
741 default_registry: Some(registry.trim().to_string()),
742 });
743 notes.push(format!("oci.default_registry = {}", registry.trim()));
744 Ok(())
745}
746
747fn setup_kubernetes(
748 config: &mut XbpConfig,
749 theme: &ColorfulTheme,
750 notes: &mut Vec<String>,
751) -> Result<(), String> {
752 let enable = Confirm::with_theme(theme)
753 .with_prompt("Configure Kubernetes deploy defaults?")
754 .default(true)
755 .interact()
756 .map_err(|e| e.to_string())?;
757 if !enable {
758 return Ok(());
759 }
760 let namespace: String = Input::with_theme(theme)
761 .with_prompt("Default namespace")
762 .with_initial_text("default")
763 .interact_text()
764 .map_err(|e| e.to_string())?;
765 let context: String = Input::with_theme(theme)
766 .with_prompt("Default kubectl context (empty = current)")
767 .allow_empty(true)
768 .interact_text()
769 .map_err(|e| e.to_string())?;
770 let require_confirm = Confirm::with_theme(theme)
771 .with_prompt("Require context confirmation before apply?")
772 .default(true)
773 .interact()
774 .map_err(|e| e.to_string())?;
775
776 config.kubernetes = Some(KubernetesProjectConfig {
777 default_context: Some(context.trim().to_string()).filter(|s| !s.is_empty()),
778 default_namespace: Some(namespace.trim().to_string()),
779 require_context_confirmation_for_apply: Some(require_confirm),
780 kubeconfig: None,
781 });
782 notes.push("kubernetes defaults saved".into());
783 Ok(())
784}
785
786fn setup_deploy_defaults(
787 config: &mut XbpConfig,
788 theme: &ColorfulTheme,
789 notes: &mut Vec<String>,
790) -> Result<(), String> {
791 let default_env: String = Input::with_theme(theme)
792 .with_prompt("Default deploy environment name")
793 .with_initial_text("production")
794 .interact_text()
795 .map_err(|e| e.to_string())?;
796
797 let service_names: Vec<String> = config
798 .services
799 .as_ref()
800 .map(|s| s.iter().map(|svc| svc.name.clone()).collect())
801 .unwrap_or_default();
802
803 let mut groups = HashMap::new();
804 if !service_names.is_empty() {
805 let create_all = Confirm::with_theme(theme)
806 .with_prompt(format!(
807 "Create deploy group `all` with {} service(s)?",
808 service_names.len()
809 ))
810 .default(true)
811 .interact()
812 .map_err(|e| e.to_string())?;
813 if create_all {
814 groups.insert(
815 "all".to_string(),
816 crate::strategies::deployment_config::DeployGroupConfig {
817 services: service_names.clone(),
818 order: service_names.clone(),
819 description: Some("All project services".into()),
820 },
821 );
822 }
823 }
824
825 config.deploy = Some(DeployProjectConfig {
826 default_env: Some(default_env.trim().to_string()),
827 history_dir: Some(".xbp/deployments".into()),
828 lock_file: Some(".xbp/deploy.lock".into()),
829 groups,
830 });
831 notes.push(format!(
832 "deploy.default_env = {}",
833 default_env.trim()
834 ));
835 Ok(())
836}
837
838fn setup_discord(
839 config: &mut XbpConfig,
840 theme: &ColorfulTheme,
841 notes: &mut Vec<String>,
842) -> Result<(), String> {
843 let enable = Confirm::with_theme(theme)
844 .with_prompt("Configure Discord release/deploy webhooks?")
845 .default(true)
846 .interact()
847 .map_err(|e| e.to_string())?;
848 if !enable {
849 return Ok(());
850 }
851
852 let modes = vec![
853 "Project webhook URL (stored in .xbp/xbp.yaml)",
854 "Env placeholder ${DISCORD_WEBHOOK_URL}",
855 "Skip (use `xbp config discord setup` later)",
856 ];
857 let idx = Select::with_theme(theme)
858 .with_prompt("Webhook configuration mode")
859 .items(&modes)
860 .default(1)
861 .interact()
862 .map_err(|e| e.to_string())?;
863
864 let webhook_url = match idx {
865 0 => {
866 let url: String = Input::with_theme(theme)
867 .with_prompt("Discord webhook URL")
868 .interact_text()
869 .map_err(|e| e.to_string())?;
870 Some(url.trim().to_string())
871 }
872 1 => Some("${DISCORD_WEBHOOK_URL}".into()),
873 _ => None,
874 };
875
876 if let Some(url) = webhook_url {
877 let username: String = Input::with_theme(theme)
878 .with_prompt("Webhook display name")
879 .with_initial_text("XBP")
880 .interact_text()
881 .map_err(|e| e.to_string())?;
882 config.discord = Some(DiscordConfig {
883 enabled: Some(true),
884 webhook_url: Some(url),
885 username: Some(username),
886 avatar_url: None,
887 events: Some(DiscordEventsConfig {
888 version_release: Some(true),
889 crates_io: Some(true),
890 npm: Some(true),
891 linear: Some(true),
892 cargo_dist: Some(true),
893 oci: Some(true),
894 }),
895 });
896 notes.push("discord webhook configured (test with `xbp config discord test`)".into());
897 } else {
898 notes.push("discord skipped".into());
899 }
900 Ok(())
901}
902
903fn setup_monitoring(
904 config: &mut XbpConfig,
905 theme: &ColorfulTheme,
906 notes: &mut Vec<String>,
907) -> Result<(), String> {
908 let enable = Confirm::with_theme(theme)
909 .with_prompt("Configure a synthetic monitor URL?")
910 .default(false)
911 .interact()
912 .map_err(|e| e.to_string())?;
913 if !enable {
914 return Ok(());
915 }
916 let url: String = Input::with_theme(theme)
917 .with_prompt("Monitor URL")
918 .with_initial_text(
919 config
920 .url
921 .clone()
922 .unwrap_or_else(|| "https://example.com/health".into()),
923 )
924 .interact_text()
925 .map_err(|e| e.to_string())?;
926 let method: String = Input::with_theme(theme)
927 .with_prompt("HTTP method")
928 .with_initial_text("GET")
929 .interact_text()
930 .map_err(|e| e.to_string())?;
931 let expected: u16 = Input::with_theme(theme)
932 .with_prompt("Expected status code")
933 .default(200)
934 .interact_text()
935 .map_err(|e| e.to_string())?;
936 let interval: u64 = Input::with_theme(theme)
937 .with_prompt("Interval seconds")
938 .default(60)
939 .interact_text()
940 .map_err(|e| e.to_string())?;
941
942 config.monitor_url = Some(url.trim().to_string());
943 config.monitor_method = Some(method.trim().to_ascii_uppercase());
944 config.monitor_expected_code = Some(expected);
945 config.monitor_interval = Some(interval);
946 notes.push("monitor_url configured".into());
947 Ok(())
948}
949
950async fn setup_github_release(
951 project_root: &Path,
952 config: &mut XbpConfig,
953 theme: &ColorfulTheme,
954 notes: &mut Vec<String>,
955) -> Result<(), String> {
956 println!(
957 " {}",
958 "Controls `xbp version release` GitHub branch creation and auto-push of generated commits."
959 .bright_black()
960 );
961
962 let detected_repo = detect_github_repository(project_root);
963 let repository: String = Input::with_theme(theme)
964 .with_prompt("GitHub repository (owner/name, empty to skip)")
965 .with_initial_text(detected_repo.unwrap_or_default())
966 .allow_empty(true)
967 .interact_text()
968 .map_err(|e| e.to_string())?;
969
970 let auto_push = Confirm::with_theme(theme)
971 .with_prompt("Auto-push commits created by XBP (config/version changes)?")
972 .default(true)
973 .interact()
974 .map_err(|e| e.to_string())?;
975
976 let enable_branch = Confirm::with_theme(theme)
977 .with_prompt("Create a dedicated GitHub release branch on `xbp version release`?")
978 .default(true)
979 .interact()
980 .map_err(|e| e.to_string())?;
981
982 let naming_template = if enable_branch {
983 println!(
984 " {} {}",
985 "Template tokens:".bright_black(),
986 "{VERSION} {SERVICE_NAME} {PROJECT_NAME} {FLAG}".dimmed()
987 );
988 let tmpl: String = Input::with_theme(theme)
989 .with_prompt("Release branch naming template")
990 .with_initial_text(DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE)
991 .interact_text()
992 .map_err(|e| e.to_string())?;
993 Some(tmpl)
994 } else {
995 None
996 };
997
998 let setup_token = Confirm::with_theme(theme)
999 .with_prompt("Configure GitHub API token now? (`xbp config github set-key`)")
1000 .default(false)
1001 .interact()
1002 .map_err(|e| e.to_string())?;
1003 if setup_token {
1004 match crate::commands::config_cmd::run_config_secret_set("github", None).await {
1005 Ok(()) => notes.push("github API token stored in global config".into()),
1006 Err(err) => {
1007 println!(" {} {}", "GitHub token:".yellow(), err.dimmed());
1008 notes.push("github token setup failed — run `xbp config github set-key` later".into());
1009 }
1010 }
1011 }
1012
1013 config.github = Some(build_github_release_config(
1014 Some(repository).filter(|s| !s.trim().is_empty()),
1015 auto_push,
1016 enable_branch,
1017 naming_template,
1018 ));
1019
1020 if enable_branch {
1021 notes.push(format!(
1022 "github.release_branch enabled (template: {})",
1023 config
1024 .github
1025 .as_ref()
1026 .and_then(|g| g.release_branch.as_ref())
1027 .and_then(|b| b.naming_template.as_deref())
1028 .unwrap_or(DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE)
1029 ));
1030 } else {
1031 notes.push("github.release_branch disabled".into());
1032 }
1033 if auto_push {
1034 notes.push("github.auto_push_on_commit = true".into());
1035 }
1036 Ok(())
1037}
1038
1039pub fn detect_github_repository(project_root: &Path) -> Option<String> {
1041 let output = std::process::Command::new("git")
1042 .args(["remote", "get-url", "origin"])
1043 .current_dir(project_root)
1044 .output()
1045 .ok()?;
1046 if !output.status.success() {
1047 return None;
1048 }
1049 let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
1050 parse_github_owner_repo(&url)
1051}
1052
1053pub fn parse_github_owner_repo(url: &str) -> Option<String> {
1055 let url = url.trim().trim_end_matches(".git");
1056 if url.is_empty() {
1057 return None;
1058 }
1059 if let Some(rest) = url.strip_prefix("git@github.com:") {
1061 let parts: Vec<&str> = rest.split('/').filter(|p| !p.is_empty()).collect();
1062 if parts.len() >= 2 {
1063 return Some(format!("{}/{}", parts[0], parts[1]));
1064 }
1065 }
1066 for prefix in [
1068 "https://github.com/",
1069 "http://github.com/",
1070 "ssh://git@github.com/",
1071 ] {
1072 if let Some(rest) = url.strip_prefix(prefix) {
1073 let parts: Vec<&str> = rest.split('/').filter(|p| !p.is_empty()).collect();
1074 if parts.len() >= 2 {
1075 return Some(format!("{}/{}", parts[0], parts[1]));
1076 }
1077 }
1078 }
1079 let parts: Vec<&str> = url.split('/').filter(|p| !p.is_empty()).collect();
1081 if parts.len() == 2 && !parts[0].contains(':') {
1082 return Some(format!("{}/{}", parts[0], parts[1]));
1083 }
1084 None
1085}
1086
1087async fn setup_linear_release(
1088 config: &mut XbpConfig,
1089 theme: &ColorfulTheme,
1090 notes: &mut Vec<String>,
1091) -> Result<(), String> {
1092 println!(
1093 " {}",
1094 "Posts release changelogs / updates to a Linear initiative during `xbp version release`."
1095 .bright_black()
1096 );
1097
1098 let enable = Confirm::with_theme(theme)
1099 .with_prompt("Post release changelogs to Linear?")
1100 .default(true)
1101 .interact()
1102 .map_err(|e| e.to_string())?;
1103
1104 if !enable {
1105 config.linear = Some(build_linear_release_config(false, Vec::new(), None));
1106 notes.push("linear.release.enabled = false".into());
1107 return Ok(());
1108 }
1109
1110 #[cfg(not(feature = "linear"))]
1111 {
1112 println!(
1113 " {}",
1114 "This CLI binary was built without `--features linear`. Enabling config only; rebuild with linear for initiative picker."
1115 .yellow()
1116 );
1117 let org: String = Input::with_theme(theme)
1118 .with_prompt("Linear organization slug (for initiative URLs, optional)")
1119 .allow_empty(true)
1120 .interact_text()
1121 .map_err(|e| e.to_string())?;
1122 let id: String = Input::with_theme(theme)
1123 .with_prompt("Linear initiative UUID (paste, or leave empty)")
1124 .allow_empty(true)
1125 .interact_text()
1126 .map_err(|e| e.to_string())?;
1127 let mut ids = Vec::new();
1128 if !id.trim().is_empty() {
1129 ids.push(id.trim().to_string());
1130 }
1131 config.linear = Some(build_linear_release_config(
1132 true,
1133 ids,
1134 Some(org).filter(|s| !s.trim().is_empty()),
1135 ));
1136 notes.push(
1137 "linear.release enabled (no interactive picker — binary lacks `linear` feature)".into(),
1138 );
1139 return Ok(());
1140 }
1141
1142 #[cfg(feature = "linear")]
1143 {
1144 let ensure_key = Confirm::with_theme(theme)
1146 .with_prompt("Ensure Linear API key is configured now?")
1147 .default(true)
1148 .interact()
1149 .map_err(|e| e.to_string())?;
1150
1151 let api_key = if ensure_key {
1152 match crate::commands::linear_cmd::ensure_linear_api_key().await {
1153 Ok(key) => Some(key),
1154 Err(err) => {
1155 println!(" {} {}", "Linear key:".yellow(), err.dimmed());
1156 None
1157 }
1158 }
1159 } else {
1160 None
1161 };
1162
1163 let org: String = Input::with_theme(theme)
1164 .with_prompt("Linear organization slug (for initiative URLs, optional)")
1165 .allow_empty(true)
1166 .with_initial_text(
1167 config
1168 .linear
1169 .as_ref()
1170 .and_then(|l| l.organization_name.clone())
1171 .unwrap_or_default(),
1172 )
1173 .interact_text()
1174 .map_err(|e| e.to_string())?;
1175
1176 let mut initiative_ids = Vec::new();
1177 if let Some(api_key) = api_key.as_deref() {
1178 match crate::commands::linear::fetch_available_initiatives(api_key).await {
1179 Ok(initiatives) if !initiatives.is_empty() => {
1180 let labels: Vec<String> = initiatives
1181 .iter()
1182 .map(|i| {
1183 let status = i.status.clone().unwrap_or_default();
1184 if status.is_empty() {
1185 i.name.clone()
1186 } else {
1187 format!("{} [{}]", i.name, status)
1188 }
1189 })
1190 .collect();
1191 let selection = dialoguer::FuzzySelect::with_theme(theme)
1192 .with_prompt("Select Linear initiative for release posts")
1193 .default(0)
1194 .items(&labels)
1195 .interact_opt()
1196 .map_err(|e| e.to_string())?;
1197 if let Some(idx) = selection {
1198 if let Some(init) = initiatives.get(idx) {
1199 initiative_ids.push(init.id.clone());
1200 println!(
1201 " {} {} ({})",
1202 "Selected:".green(),
1203 init.name.bright_white(),
1204 init.id.dimmed()
1205 );
1206 }
1207 }
1208 }
1209 Ok(_) => {
1210 println!(
1211 " {}",
1212 "No Linear initiatives found for this API key.".yellow()
1213 );
1214 }
1215 Err(err) => {
1216 println!(
1217 " {} {}",
1218 "Could not list initiatives:".yellow(),
1219 err.dimmed()
1220 );
1221 }
1222 }
1223 }
1224
1225 if initiative_ids.is_empty() {
1226 let manual: String = Input::with_theme(theme)
1227 .with_prompt("Paste Linear initiative UUID (empty to enable without binding one)")
1228 .allow_empty(true)
1229 .interact_text()
1230 .map_err(|e| e.to_string())?;
1231 if !manual.trim().is_empty() {
1232 initiative_ids.push(manual.trim().to_string());
1233 }
1234 }
1235
1236 let team_key: String = Input::with_theme(theme)
1238 .with_prompt("Default Linear team key (e.g. XLX, empty to skip)")
1239 .allow_empty(true)
1240 .interact_text()
1241 .map_err(|e| e.to_string())?;
1242
1243 let mut linear = build_linear_release_config(
1244 true,
1245 initiative_ids.clone(),
1246 Some(org.clone()).filter(|s| !s.trim().is_empty()),
1247 );
1248 if !team_key.trim().is_empty() {
1249 linear.default_team_key = Some(team_key.trim().to_string());
1250 }
1251 config.linear = Some(linear);
1252
1253 if initiative_ids.is_empty() {
1254 notes.push(
1255 "linear.release enabled without initiative — run `xbp config linear select-initiative` later"
1256 .into(),
1257 );
1258 } else {
1259 notes.push(format!(
1260 "linear.release → initiative {}",
1261 initiative_ids.join(", ")
1262 ));
1263 }
1264 Ok(())
1265 }
1266}
1267
1268fn split_csv(raw: &str) -> Vec<String> {
1269 raw.split(',')
1270 .map(|s| s.trim().to_string())
1271 .filter(|s| !s.is_empty())
1272 .collect()
1273}
1274
1275#[cfg(test)]
1276mod tests {
1277 use super::*;
1278 use std::fs;
1279 use std::time::{SystemTime, UNIX_EPOCH};
1280
1281 fn temp_dir(label: &str) -> PathBuf {
1282 let nanos = SystemTime::now()
1283 .duration_since(UNIX_EPOCH)
1284 .map(|d| d.as_nanos())
1285 .unwrap_or(0);
1286 let dir = std::env::temp_dir().join(format!("xbp-init-wizard-{label}-{nanos}"));
1287 fs::create_dir_all(&dir).expect("temp dir");
1288 dir
1289 }
1290
1291 #[test]
1292 fn default_modules_include_cargo_and_workers_when_markers_present() {
1293 let mods = default_modules_for_project(true, true, true, true, true);
1294 assert!(mods.contains(&InitSetupModule::CargoDist));
1295 assert!(mods.contains(&InitSetupModule::PublishCrates));
1296 assert!(mods.contains(&InitSetupModule::PublishNpm));
1297 assert!(mods.contains(&InitSetupModule::CloudflareWorkers));
1298 assert!(mods.contains(&InitSetupModule::OciRegistry));
1299 assert!(mods.contains(&InitSetupModule::Kubernetes));
1300 assert!(mods.contains(&InitSetupModule::Discord));
1301 }
1302
1303 #[test]
1304 fn default_modules_minimal_without_markers() {
1305 let mods = default_modules_for_project(false, false, false, false, false);
1306 assert!(mods.contains(&InitSetupModule::VersionTargets));
1307 assert!(mods.contains(&InitSetupModule::Discord));
1308 assert!(!mods.contains(&InitSetupModule::PublishCrates));
1309 assert!(!mods.contains(&InitSetupModule::CloudflareWorkers));
1310 }
1311
1312 #[test]
1313 fn default_modules_cargo_only() {
1314 let mods = default_modules_for_project(true, false, false, false, false);
1315 assert!(mods.contains(&InitSetupModule::CargoDist));
1316 assert!(mods.contains(&InitSetupModule::PublishCrates));
1317 assert!(!mods.contains(&InitSetupModule::PublishNpm));
1318 assert!(!mods.contains(&InitSetupModule::OciRegistry));
1319 }
1320
1321 #[test]
1322 fn default_modules_npm_only() {
1323 let mods = default_modules_for_project(false, true, false, false, false);
1324 assert!(mods.contains(&InitSetupModule::PublishNpm));
1325 assert!(!mods.contains(&InitSetupModule::PublishCrates));
1326 assert!(!mods.contains(&InitSetupModule::CargoDist));
1327 }
1328
1329 #[test]
1330 fn default_modules_dockerfile_enables_oci() {
1331 let mods = default_modules_for_project(false, false, false, true, false);
1332 assert!(mods.contains(&InitSetupModule::OciRegistry));
1333 assert!(!mods.contains(&InitSetupModule::Kubernetes));
1334 }
1335
1336 #[test]
1337 fn default_modules_k8s_without_docker() {
1338 let mods = default_modules_for_project(false, false, false, false, true);
1339 assert!(mods.contains(&InitSetupModule::Kubernetes));
1340 assert!(!mods.contains(&InitSetupModule::OciRegistry));
1341 }
1342
1343 #[test]
1344 fn default_modules_always_include_deploy_defaults() {
1345 for cargo in [false, true] {
1346 for npm in [false, true] {
1347 let mods = default_modules_for_project(cargo, npm, false, false, false);
1348 assert!(
1349 mods.contains(&InitSetupModule::DeployDefaults),
1350 "missing DeployDefaults cargo={cargo} npm={npm}"
1351 );
1352 }
1353 }
1354 }
1355
1356 #[test]
1357 fn detect_markers_finds_cargo_and_wrangler() {
1358 let dir = temp_dir("markers");
1359 fs::write(dir.join("Cargo.toml"), "[package]\nname=\"x\"\n").unwrap();
1360 fs::write(dir.join("wrangler.toml"), "name = \"x\"\n").unwrap();
1361 let m = detect_project_markers(&dir);
1362 assert!(m.has_cargo_toml);
1363 assert!(m.has_wrangler);
1364 let _ = fs::remove_dir_all(dir);
1365 }
1366
1367 #[test]
1368 fn detect_markers_finds_apps_package_json_and_k8s() {
1369 let dir = temp_dir("apps-k8s");
1370 fs::create_dir_all(dir.join("apps/web")).unwrap();
1371 fs::write(dir.join("apps/web/package.json"), "{}").unwrap();
1372 fs::create_dir_all(dir.join("k8s")).unwrap();
1373 fs::write(dir.join("Dockerfile"), "FROM scratch\n").unwrap();
1374 fs::write(dir.join("dist-workspace.toml"), "[workspace]\n").unwrap();
1375 let m = detect_project_markers(&dir);
1376 assert!(m.has_package_json);
1377 assert!(m.has_k8s_manifests);
1378 assert!(m.has_dockerfile);
1379 assert!(m.has_dist_workspace);
1380 let _ = fs::remove_dir_all(dir);
1381 }
1382
1383 #[test]
1384 fn detect_markers_finds_wrangler_jsonc_under_apps_web() {
1385 let dir = temp_dir("wrangler-jsonc");
1386 fs::create_dir_all(dir.join("apps/web")).unwrap();
1387 fs::write(dir.join("apps/web/wrangler.jsonc"), "{}\n").unwrap();
1388 let m = detect_project_markers(&dir);
1389 assert!(m.has_wrangler);
1390 assert!(find_wrangler_config(&dir).is_some());
1391 let _ = fs::remove_dir_all(dir);
1392 }
1393
1394 #[test]
1395 fn detect_markers_empty_tree() {
1396 let dir = temp_dir("empty");
1397 let m = detect_project_markers(&dir);
1398 assert!(!m.has_cargo_toml);
1399 assert!(!m.has_package_json);
1400 assert!(!m.has_wrangler);
1401 assert!(!m.has_dockerfile);
1402 assert!(!m.has_k8s_manifests);
1403 assert!(!m.has_dist_workspace);
1404 let _ = fs::remove_dir_all(dir);
1405 }
1406
1407 #[test]
1408 fn discover_worker_roots_from_apps() {
1409 let dir = temp_dir("workers");
1410 fs::create_dir_all(dir.join("apps/web")).unwrap();
1411 fs::create_dir_all(dir.join("apps/api")).unwrap();
1412 fs::create_dir_all(dir.join("apps/docs")).unwrap();
1413 fs::write(dir.join("apps/web/wrangler.jsonc"), "{}\n").unwrap();
1414 fs::write(dir.join("apps/docs/wrangler.toml"), "name=\"d\"\n").unwrap();
1415 let roots = discover_worker_roots(&dir);
1417 assert_eq!(roots.len(), 2);
1418 assert!(roots.iter().any(|p| p.ends_with("web")));
1419 assert!(roots.iter().any(|p| p.ends_with("docs")));
1420 let _ = fs::remove_dir_all(dir);
1421 }
1422
1423 #[test]
1424 fn discover_worker_roots_includes_repo_root_wrangler() {
1425 let dir = temp_dir("root-worker");
1426 fs::write(dir.join("wrangler.toml"), "name=\"root\"\n").unwrap();
1427 let roots = discover_worker_roots(&dir);
1428 assert!(roots.iter().any(|p| p == &dir));
1429 let _ = fs::remove_dir_all(dir);
1430 }
1431
1432 #[test]
1433 fn all_modules_have_unique_labels() {
1434 let labels: Vec<_> = InitSetupModule::all().iter().map(|m| m.label()).collect();
1435 let mut set = std::collections::BTreeSet::new();
1436 for l in &labels {
1437 assert!(set.insert(*l), "duplicate label {l}");
1438 }
1439 assert_eq!(labels.len(), 12);
1440 }
1441
1442 #[test]
1443 fn all_modules_labels_are_non_empty() {
1444 for m in InitSetupModule::all() {
1445 assert!(!m.label().is_empty());
1446 }
1447 }
1448
1449 #[test]
1450 fn default_selected_matches_expected_set() {
1451 assert!(InitSetupModule::VersionTargets.default_selected());
1452 assert!(InitSetupModule::GitHubRelease.default_selected());
1453 assert!(InitSetupModule::LinearRelease.default_selected());
1454 assert!(InitSetupModule::CargoDist.default_selected());
1455 assert!(InitSetupModule::PublishCrates.default_selected());
1456 assert!(InitSetupModule::Discord.default_selected());
1457 assert!(InitSetupModule::DeployDefaults.default_selected());
1458 assert!(!InitSetupModule::PublishNpm.default_selected());
1459 assert!(!InitSetupModule::CloudflareWorkers.default_selected());
1460 assert!(!InitSetupModule::OciRegistry.default_selected());
1461 assert!(!InitSetupModule::Kubernetes.default_selected());
1462 assert!(!InitSetupModule::Monitoring.default_selected());
1463 }
1464
1465 #[test]
1466 fn build_github_release_config_enabled_branch() {
1467 let cfg = build_github_release_config(
1468 Some("xylex-group/xbp".into()),
1469 true,
1470 true,
1471 Some("releases/{VERSION}-{SERVICE_NAME}".into()),
1472 );
1473 assert_eq!(cfg.repository.as_deref(), Some("xylex-group/xbp"));
1474 assert!(cfg.auto_push_on_commit);
1475 let branch = cfg.release_branch.expect("branch");
1476 assert!(branch.enabled);
1477 assert_eq!(
1478 branch.naming_template.as_deref(),
1479 Some("releases/{VERSION}-{SERVICE_NAME}")
1480 );
1481 }
1482
1483 #[test]
1484 fn build_github_release_config_uses_default_template() {
1485 let cfg = build_github_release_config(None, false, true, None);
1486 let branch = cfg.release_branch.expect("branch");
1487 assert_eq!(
1488 branch.naming_template.as_deref(),
1489 Some(DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE)
1490 );
1491 assert!(!cfg.auto_push_on_commit);
1492 }
1493
1494 #[test]
1495 fn build_github_release_config_disabled_branch() {
1496 let cfg = build_github_release_config(Some("o/r".into()), true, false, Some("x".into()));
1497 assert!(cfg.release_branch.is_none());
1498 }
1499
1500 #[test]
1501 fn build_linear_release_config_enabled_with_initiative() {
1502 let cfg = build_linear_release_config(
1503 true,
1504 vec!["init-uuid".into()],
1505 Some("xylex".into()),
1506 );
1507 let release = cfg.release.expect("release");
1508 assert_eq!(release.enabled, Some(true));
1509 assert_eq!(
1510 release.initiative_ids.clone().unwrap_or_default(),
1511 vec!["init-uuid".to_string()]
1512 );
1513 assert_eq!(release.organization_name.as_deref(), Some("xylex"));
1514 assert_eq!(cfg.organization_name.as_deref(), Some("xylex"));
1515 }
1516
1517 #[test]
1518 fn build_linear_release_config_disabled() {
1519 let cfg = build_linear_release_config(false, Vec::new(), None);
1520 assert_eq!(cfg.release.as_ref().and_then(|r| r.enabled), Some(false));
1521 assert!(cfg
1522 .release
1523 .as_ref()
1524 .and_then(|r| r.initiative_ids.as_ref())
1525 .is_none());
1526 }
1527
1528 #[test]
1529 fn parse_github_owner_repo_forms() {
1530 assert_eq!(
1531 parse_github_owner_repo("git@github.com:xylex-group/xbp.git").as_deref(),
1532 Some("xylex-group/xbp")
1533 );
1534 assert_eq!(
1535 parse_github_owner_repo("https://github.com/xylex-group/xbp").as_deref(),
1536 Some("xylex-group/xbp")
1537 );
1538 assert_eq!(
1539 parse_github_owner_repo("https://github.com/xylex-group/xbp.git").as_deref(),
1540 Some("xylex-group/xbp")
1541 );
1542 assert_eq!(
1543 parse_github_owner_repo("xylex-group/xbp").as_deref(),
1544 Some("xylex-group/xbp")
1545 );
1546 assert_eq!(parse_github_owner_repo("not-a-repo"), None);
1547 assert_eq!(parse_github_owner_repo(""), None);
1548 }
1549
1550 #[test]
1551 fn split_csv_trims() {
1552 assert_eq!(split_csv(" shell , msi , "), vec!["shell", "msi"]);
1553 }
1554
1555 #[test]
1556 fn split_csv_empty_and_whitespace() {
1557 assert!(split_csv("").is_empty());
1558 assert!(split_csv(" , , ").is_empty());
1559 assert_eq!(split_csv("a"), vec!["a"]);
1560 assert_eq!(split_csv("a,b,c"), vec!["a", "b", "c"]);
1561 }
1562
1563 #[test]
1564 fn module_all_is_stable_order() {
1565 let a = InitSetupModule::all();
1566 let b = InitSetupModule::all();
1567 assert_eq!(a, b);
1568 assert_eq!(a[0], InitSetupModule::VersionTargets);
1569 assert_eq!(a[1], InitSetupModule::GitHubRelease);
1570 assert_eq!(a[2], InitSetupModule::LinearRelease);
1571 assert_eq!(*a.last().unwrap(), InitSetupModule::Monitoring);
1572 }
1573
1574 #[test]
1575 fn default_modules_include_github_and_linear_release() {
1576 let mods = default_modules_for_project(false, false, false, false, false);
1577 assert!(mods.contains(&InitSetupModule::GitHubRelease));
1578 assert!(mods.contains(&InitSetupModule::LinearRelease));
1579 }
1580}