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