Skip to main content

xbp_cli/commands/
init_wizard.rs

1//! Comprehensive interactive post-init setup wizard.
2//!
3//! After the base `.xbp/xbp.yaml` is written, `xbp init` can walk through
4//! versioning, cargo-dist, publish targets, Cloudflare Workers, OCI, Kubernetes,
5//! Discord webhooks, monitoring, and related project knobs.
6
7use 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/// Setup modules offered by the interactive init wizard.
23#[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
88/// Pure planner used by tests: which modules should default on for a tree shape.
89pub 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
122/// Build a `github.release_branch` config from wizard answers (pure; unit-tested).
123pub 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
151/// Build `linear.release` config from wizard answers (pure; unit-tested).
152pub 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
177/// Detect common project markers used to pre-select wizard modules.
178pub 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
219/// Run the interactive full-setup wizard and persist updates to `.xbp/xbp.yaml`.
220pub 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
1039/// Detect `owner/repo` from `git remote get-url origin` when possible.
1040pub 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
1053/// Parse `owner/repo` from common GitHub remote URL forms (pure; unit-tested).
1054pub 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    // git@github.com:owner/repo
1060    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    // https://github.com/owner/repo
1067    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    // already owner/repo
1080    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        // Credentials first.
1145        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        // Optional default team for `xbp linear create`
1237        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        // no wrangler in api
1416        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}