use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use colored::Colorize;
use crate::cli::interactive::{confirm, print_picker_header, searchable_multi_select};
use crate::strategies::deployment_config::{ServiceConfig, XbpConfig};
use crate::utils::{find_xbp_config_upwards, write_xbp_project_config_at_path};
#[derive(Debug, Clone, Default)]
pub struct DeployResetRequest {
pub kubernetes: bool,
pub deploy_config: bool,
pub service_deploy: bool,
pub history: bool,
pub services: Vec<String>,
pub env: Option<String>,
pub full_service_deploy: bool,
pub dry_run: bool,
pub yes: bool,
}
impl DeployResetRequest {
pub fn any_scope(&self) -> bool {
self.kubernetes
|| self.deploy_config
|| self.service_deploy
|| self.history
|| self.env.as_ref().is_some_and(|e| !e.trim().is_empty())
}
pub fn enable_all(&mut self) {
self.kubernetes = true;
self.deploy_config = true;
self.service_deploy = true;
self.history = true;
}
}
#[derive(Debug, Clone, Default)]
pub struct DeployResetReport {
pub notes: Vec<String>,
pub wrote_config: bool,
pub deleted_paths: Vec<PathBuf>,
}
pub fn run_deploy_reset(
project_root: &Path,
mut config: XbpConfig,
mut request: DeployResetRequest,
) -> Result<DeployResetReport, String> {
if !request.any_scope() {
if secrets_is_interactive() && !request.yes {
request = prompt_reset_scopes(request)?;
} else {
return Err(
"deploy reset requires a scope. Pass --all, or one of \
--reset-kubernetes / --reset-deploy-config / --reset-service-deploy / \
--reset-history / --reset-env <ENV>. In a TTY, omit scopes to pick interactively."
.into(),
);
}
}
if !request.any_scope() {
return Err("deploy reset cancelled: no scopes selected".into());
}
let summary = summarize_reset(&config, project_root, &request);
println!();
println!(
"{} {}",
"Deploy reset".bright_cyan().bold(),
"(config + local history only — not cluster resources)".dimmed()
);
for line in &summary {
println!(" {} {}", "·".bright_yellow(), line);
}
println!();
if request.dry_run {
println!(
"{} {}",
"[dry-run]".bright_yellow(),
"No files written or deleted.".bright_black()
);
return Ok(DeployResetReport {
notes: summary,
wrote_config: false,
deleted_paths: vec![],
});
}
let proceed = if request.yes {
true
} else if secrets_is_interactive() {
confirm("Apply deploy/kubernetes settings reset?", false)?
} else {
return Err(
"deploy reset requires confirmation. Re-run with --yes, or in an interactive TTY."
.into(),
);
};
if !proceed {
return Err("deploy reset cancelled".into());
}
let mut report = DeployResetReport::default();
let history_targets = if request.history {
Some(history_paths(project_root, &config))
} else {
None
};
apply_reset_to_config(&mut config, &request, &mut report.notes);
let config_path = find_xbp_config_upwards(project_root)
.map(|f| f.config_path)
.ok_or_else(|| "No project config found to write".to_string())?;
write_xbp_project_config_at_path(&config_path, &config)?;
report.wrote_config = true;
report.notes.push(format!("wrote {}", config_path.display()));
if let Some((hist, lock)) = history_targets {
for path in delete_paths(&[hist, lock])? {
report
.notes
.push(format!("deleted {}", path.display()));
report.deleted_paths.push(path);
}
}
println!(
"{} deploy settings reset complete",
"✓".bright_green().bold()
);
for note in &report.notes {
println!(" {} {}", "·".bright_black(), note.dimmed());
}
println!();
println!(
"Re-run {} to recreate deploy.envs via the setup wizard.",
"xbp deploy <target> --env <env>".bright_white().bold()
);
Ok(report)
}
fn secrets_is_interactive() -> bool {
use std::io::IsTerminal;
std::io::stdin().is_terminal()
&& std::io::stdout().is_terminal()
&& std::env::var_os("XBP_NON_INTERACTIVE").is_none()
}
fn prompt_reset_scopes(mut request: DeployResetRequest) -> Result<DeployResetRequest, String> {
print_picker_header(
"xbp deploy · reset scopes",
"Space toggles · Enter confirms · config/local only (not cluster)",
);
let labels = [
"kubernetes: project block",
"deploy: groups + default_env / history paths",
"services[].deploy (CF-aware k8s clear by default)",
"local history dir + deploy lock file",
"ALL of the above",
];
let defaults = [false, false, false, false, false];
let selected = searchable_multi_select("Reset which scopes?", &labels, &defaults)?;
if selected.is_empty() {
return Ok(request);
}
if selected.contains(&4) {
request.enable_all();
return Ok(request);
}
if selected.contains(&0) {
request.kubernetes = true;
}
if selected.contains(&1) {
request.deploy_config = true;
}
if selected.contains(&2) {
request.service_deploy = true;
}
if selected.contains(&3) {
request.history = true;
}
Ok(request)
}
fn summarize_reset(config: &XbpConfig, project_root: &Path, request: &DeployResetRequest) -> Vec<String> {
let mut lines = Vec::new();
if request.kubernetes {
lines.push(if config.kubernetes.is_some() {
"clear project kubernetes:".into()
} else {
"kubernetes: already empty".into()
});
}
if request.deploy_config {
let groups = config
.deploy
.as_ref()
.map(|d| d.groups.len())
.unwrap_or(0);
lines.push(format!("clear project deploy: (groups={groups}, default_env, paths)"));
}
if let Some(env) = request.env.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
lines.push(format!("remove services[].deploy.envs.{env}"));
} else if request.service_deploy {
let mode = if request.full_service_deploy {
"full wipe"
} else {
"CF-aware (keep cloudflare provider fields)"
};
let scope = if request.services.is_empty() {
"all services".into()
} else {
format!("services: {}", request.services.join(", "))
};
lines.push(format!("reset services[].deploy ({mode}) · {scope}"));
}
if request.history {
let (hist, lock) = history_paths(project_root, config);
lines.push(format!(
"delete history {} and lock {}",
hist.display(),
lock.display()
));
}
lines
}
pub fn apply_reset_to_config(
config: &mut XbpConfig,
request: &DeployResetRequest,
notes: &mut Vec<String>,
) {
if request.kubernetes {
if config.kubernetes.take().is_some() {
notes.push("cleared kubernetes:".into());
}
}
if request.deploy_config {
match config.deploy.as_mut() {
Some(deploy) => {
let n = deploy.groups.len();
deploy.groups.clear();
deploy.default_env = None;
deploy.history_dir = None;
deploy.lock_file = None;
notes.push(format!("cleared deploy.groups ({n}) and default_env/paths"));
}
None => notes.push("deploy: already absent".into()),
}
if let Some(deploy) = config.deploy.as_ref() {
if deploy.groups.is_empty()
&& deploy.default_env.is_none()
&& deploy.history_dir.is_none()
&& deploy.lock_file.is_none()
{
config.deploy = None;
}
}
}
let env_only = request
.env
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
if let Some(env) = env_only {
let filter = service_filter(&request.services);
let mut removed = 0usize;
if let Some(services) = config.services.as_mut() {
for svc in services.iter_mut() {
if !service_matches(svc, &filter) {
continue;
}
if let Some(deploy) = svc.deploy.as_mut() {
if deploy.envs.remove(&env).is_some() {
removed += 1;
}
if deploy.envs.is_empty()
&& deploy.destinations.is_empty()
&& deploy.provider.is_none()
&& deploy.worker.is_none()
&& deploy.rollout.is_none()
{
svc.deploy = None;
}
}
}
}
notes.push(format!(
"removed deploy.envs.{env} from {removed} service(s)"
));
} else if request.service_deploy {
let filter = service_filter(&request.services);
let mut touched = 0usize;
if let Some(services) = config.services.as_mut() {
for svc in services.iter_mut() {
if !service_matches(svc, &filter) {
continue;
}
if svc.deploy.is_none() {
continue;
}
if request.full_service_deploy {
svc.deploy = None;
touched += 1;
continue;
}
clear_service_deploy_cf_aware(svc);
touched += 1;
}
}
notes.push(format!("reset services[].deploy on {touched} service(s)"));
}
}
fn service_filter(names: &[String]) -> Option<HashSet<String>> {
if names.is_empty() {
return None;
}
Some(
names
.iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
)
}
fn service_matches(svc: &ServiceConfig, filter: &Option<HashSet<String>>) -> bool {
match filter {
None => true,
Some(set) => set.contains(&svc.name),
}
}
fn clear_service_deploy_cf_aware(svc: &mut ServiceConfig) {
let Some(deploy) = svc.deploy.as_mut() else {
return;
};
let provider = deploy.provider.as_deref().unwrap_or("").to_ascii_lowercase();
let is_cf = provider.contains("cloudflare")
|| provider == "worker"
|| deploy.destinations.keys().any(|k| {
let k = k.to_ascii_lowercase();
k.contains("cloudflare") || k == "worker"
})
|| deploy.worker.is_some();
if !is_cf {
svc.deploy = None;
return;
}
deploy.envs.clear();
deploy.destinations.retain(|name, _| {
let n = name.to_ascii_lowercase();
n.contains("cloudflare") || n == "worker"
});
if !provider.contains("cloudflare") && provider != "worker" {
if deploy.worker.is_some()
|| deploy
.destinations
.values()
.any(|d| d.provider.as_deref().is_some_and(|p| p.contains("cloudflare")))
{
deploy.provider = Some("cloudflare-containers".into());
}
}
if deploy.envs.is_empty()
&& deploy.destinations.is_empty()
&& deploy.provider.as_deref().is_some_and(|p| {
let p = p.to_ascii_lowercase();
p.contains("kubernetes") || p == "k8s"
})
{
svc.deploy = None;
}
}
fn history_paths(project_root: &Path, config: &XbpConfig) -> (PathBuf, PathBuf) {
let hist_rel = config
.deploy
.as_ref()
.and_then(|d| d.history_dir.as_deref())
.filter(|s| !s.trim().is_empty())
.unwrap_or(".xbp/deployments");
let lock_rel = config
.deploy
.as_ref()
.and_then(|d| d.lock_file.as_deref())
.filter(|s| !s.trim().is_empty())
.unwrap_or(".xbp/deploy-lock.json");
(project_root.join(hist_rel), project_root.join(lock_rel))
}
fn delete_paths(paths: &[PathBuf]) -> Result<Vec<PathBuf>, String> {
let mut deleted = Vec::new();
for path in paths {
if !path.exists() {
continue;
}
if path.is_dir() {
fs::remove_dir_all(path)
.map_err(|e| format!("Failed to remove {}: {e}", path.display()))?;
} else {
fs::remove_file(path)
.map_err(|e| format!("Failed to remove {}: {e}", path.display()))?;
}
deleted.push(path.clone());
}
Ok(deleted)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_config() -> XbpConfig {
serde_json::from_value(serde_json::json!({
"project_name": "demo",
"version": "0.1.0",
"port": 3000,
"build_dir": "./",
"kubernetes": {
"default_context": "docker-desktop",
"default_namespace": "default"
},
"deploy": {
"default_env": "production",
"history_dir": ".xbp/deployments",
"lock_file": ".xbp/deploy-lock.json",
"groups": {
"all": {
"services": ["api", "web"]
}
}
},
"services": [
{
"name": "api",
"target": "docker",
"branch": "main",
"port": 8080,
"deploy": {
"provider": "kubernetes",
"envs": {
"production": { "namespace": "prod" },
"staging": { "namespace": "stg" }
}
}
},
{
"name": "web",
"target": "nodejs",
"branch": "main",
"port": 3000,
"deploy": {
"provider": "cloudflare-containers",
"worker": "web",
"envs": {
"production": { "namespace": "prod" },
"staging": { "namespace": "stg" }
},
"destinations": {
"cloudflare": { "provider": "cloudflare-containers" },
"kubernetes": { "provider": "kubernetes" }
}
}
}
]
}))
.expect("sample XbpConfig")
}
#[test]
fn reset_kubernetes_only() {
let mut config = sample_config();
let mut notes = Vec::new();
apply_reset_to_config(
&mut config,
&DeployResetRequest {
kubernetes: true,
..Default::default()
},
&mut notes,
);
assert!(config.kubernetes.is_none());
assert!(config.services.as_ref().unwrap()[0].deploy.is_some());
}
#[test]
fn reset_env_only() {
let mut config = sample_config();
let mut notes = Vec::new();
apply_reset_to_config(
&mut config,
&DeployResetRequest {
env: Some("production".into()),
..Default::default()
},
&mut notes,
);
let api = &config.services.as_ref().unwrap()[0];
let envs = &api.deploy.as_ref().unwrap().envs;
assert!(!envs.contains_key("production"));
assert!(envs.contains_key("staging"));
}
#[test]
fn cf_aware_keeps_cloudflare() {
let mut config = sample_config();
let mut notes = Vec::new();
apply_reset_to_config(
&mut config,
&DeployResetRequest {
service_deploy: true,
full_service_deploy: false,
..Default::default()
},
&mut notes,
);
let services = config.services.as_ref().unwrap();
assert!(services[0].deploy.is_none()); let web = services[1].deploy.as_ref().unwrap();
assert!(web.worker.as_deref() == Some("web"));
assert!(web.envs.is_empty());
assert!(web.destinations.contains_key("cloudflare"));
assert!(!web.destinations.contains_key("kubernetes"));
}
#[test]
fn full_wipe_clears_cf_service() {
let mut config = sample_config();
let mut notes = Vec::new();
apply_reset_to_config(
&mut config,
&DeployResetRequest {
service_deploy: true,
full_service_deploy: true,
services: vec!["web".into()],
..Default::default()
},
&mut notes,
);
assert!(config.services.as_ref().unwrap()[1].deploy.is_none());
assert!(config.services.as_ref().unwrap()[0].deploy.is_some());
}
}