Skip to main content

xbp_cli/commands/
init_wizard.rs

1//! Comprehensive interactive post-init setup wizard.
2//!
3//! After the base project config is written (`.xbp/xbp.toml` by default),
4//! `xbp init` walks through versioning, cargo-dist, publish targets,
5//! Cloudflare Workers, OCI, Kubernetes, Discord webhooks, monitoring,
6//! and related project knobs.
7
8use 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/// Setup modules offered by the interactive init wizard.
26#[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
91/// Pure planner used by tests: which modules should default on for a tree shape.
92pub 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
125/// Build a `github.release_branch` config from wizard answers (pure; unit-tested).
126pub 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            // Drop invalid custom templates so the default is written instead.
137            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
162/// Build `linear.release` config from wizard answers (pure; unit-tested).
163pub 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
188/// Detect common project markers used to pre-select wizard modules.
189pub 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
230/// Run the interactive full-setup wizard and persist updates to project config.
231///
232/// Always enters the module picker — `xbp init` is the wizard entrypoint.
233pub 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    // Apply build/start/oci/deploy lifecycle defaults to every service after modules ran.
328    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
681/// Default repo-relative Worker root derived from the worker name (`apps/{name}`).
682///
683/// Sanitizes path-hostile characters so a typed name stays a single path segment.
684pub 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
1103/// Detect `owner/repo` from `git remote get-url origin` when possible.
1104pub 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
1117/// Parse `owner/repo` from common GitHub remote URL forms (pure; unit-tested).
1118pub 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    // git@github.com:owner/repo
1124    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    // https://github.com/owner/repo
1131    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    // already owner/repo
1144    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        // Credentials first.
1209        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        // Optional default team for `xbp linear create`
1301        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        // no wrangler in api
1490        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}