use std::collections::{BTreeMap, BTreeSet};
use anyhow::Result;
use console::style;
use dialoguer::{Confirm, Input};
use ryra_core::registry::service_def::EnvKind;
use ryra_core::{DiffKind, EnvAddition, GeneratedFile, Step, UpgradeResult};
use super::apply;
pub async fn run(services: &[String], yes: bool, force: bool, dry_run: bool) -> Result<()> {
let targets = resolve_targets(services)?;
if targets.is_empty() {
println!("No services installed.");
return Ok(());
}
let mut plans: Vec<UpgradeResult> = Vec::with_capacity(targets.len());
for service in &targets {
match ryra_core::upgrade_service(service, force).await {
Ok(plan) => plans.push(plan),
Err(ryra_core::error::Error::HandEditedFiles { service, paths }) => {
eprintln!(
"{} {service}: {} hand-edited file(s):",
style("error").red().bold(),
paths.len()
);
for p in &paths {
eprintln!(" {} {}", style("!").red().bold(), p.display());
}
eprintln!();
eprintln!(
"Re-run with {} to overwrite (backups land in {}), or back up the changes first.",
style("--force").bold(),
style("~/.local/state/ryra/backups/").dim()
);
anyhow::bail!("upgrade aborted");
}
Err(e) => return Err(e.into()),
}
}
let any_change = plans.iter().any(|p| !p.diff.is_clean());
if !any_change {
println!("Everything up to date.");
return Ok(());
}
print_summary(&plans);
if dry_run {
println!("Dry run — no changes made. Remove --dry-run to apply.\n");
return Ok(());
}
for plan in plans.iter_mut() {
prompt_and_patch_env_additions(plan)?;
}
if !yes {
if super::is_interactive() {
let proceed = Confirm::new()
.with_prompt("Apply upgrade?")
.default(true)
.interact()?;
if !proceed {
println!("Cancelled.");
return Ok(());
}
} else {
anyhow::bail!(
"non-interactive run without --yes — re-run with `ryra upgrade --yes` (or --dry-run to preview)"
);
}
}
for plan in &plans {
if plan.diff.is_clean() {
continue;
}
println!();
println!("Upgrading {}…", style(&plan.service).bold());
apply::execute_all(&plan.steps).await?;
if let Some(backup) = &plan.backup_dir {
println!(
" {} {}",
style("Backed up to").dim(),
style(backup.display()).dim()
);
}
match ryra_core::prune_backups(&plan.service, ryra_core::DEFAULT_BACKUP_KEEP) {
Ok(pruned) if !pruned.is_empty() => {
println!(
" {} pruned {} older backup(s) (keep={})",
style("⌫").dim(),
pruned.len(),
ryra_core::DEFAULT_BACKUP_KEEP
);
}
Ok(_) => {}
Err(e) => {
eprintln!(" warning: backup prune failed: {e}");
}
}
}
println!();
println!("Done.");
Ok(())
}
fn prompt_and_patch_env_additions(plan: &mut UpgradeResult) -> Result<()> {
if plan.diff.env_additions.is_empty() {
return Ok(());
}
let needs_prompt: Vec<&EnvAddition> = plan
.diff
.env_additions
.iter()
.filter(|a| matches!(a.kind, EnvKind::Prompted | EnvKind::Required))
.collect();
if needs_prompt.is_empty() {
return Ok(());
}
let interactive = super::is_interactive();
let has_required = needs_prompt
.iter()
.any(|a| matches!(a.kind, EnvKind::Required));
if !interactive && has_required {
anyhow::bail!(
"{}: registry adds required env var(s); re-run interactively or pre-populate them in `.env`:\n {}",
plan.service,
needs_prompt
.iter()
.filter(|a| matches!(a.kind, EnvKind::Required))
.map(|a| a.key.as_str())
.collect::<Vec<_>>()
.join("\n ")
);
}
let mut overrides: BTreeMap<String, String> = BTreeMap::new();
if interactive {
println!();
println!("Confirm env values for {}:", style(&plan.service).bold());
for add in &needs_prompt {
let label = add.prompt.as_deref().unwrap_or(&add.key);
let value = match add.kind {
EnvKind::Required => Input::<String>::new()
.with_prompt(format!(" {label} (required)"))
.interact_text()?,
EnvKind::Prompted => Input::<String>::new()
.with_prompt(format!(" {label}"))
.default(add.value.clone())
.interact_text()?,
EnvKind::Default => continue,
};
if value != add.value {
overrides.insert(add.key.clone(), value);
}
}
}
if overrides.is_empty() {
return Ok(());
}
for add in plan.diff.env_additions.iter_mut() {
if let Some(v) = overrides.get(&add.key) {
add.value = v.clone();
}
}
let env_path = ryra_core::service_home(&plan.service)?.join(".env");
let mut content = match std::fs::read_to_string(&env_path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => anyhow::bail!("read {}: {e}", env_path.display()),
};
if !content.is_empty() && !content.ends_with('\n') {
content.push('\n');
}
for add in &plan.diff.env_additions {
content.push_str(&format!("{}={}\n", add.key, add.value));
}
let mut replaced = false;
for step in plan.steps.iter_mut() {
if let Step::WriteFile(file) = step
&& file.path == env_path
{
file.content = content.clone();
replaced = true;
break;
}
}
if !replaced {
plan.steps.push(Step::WriteFile(GeneratedFile {
path: env_path,
content,
}));
}
Ok(())
}
fn print_summary(plans: &[UpgradeResult]) {
for plan in plans {
if plan.diff.is_clean() {
continue;
}
println!("{}", style(&plan.service).bold());
for entry in &plan.diff.entries {
match entry.kind {
DiffKind::Unchanged => {}
DiffKind::Added => println!(
" {} {} {}",
style("+").green().bold(),
entry.path.display(),
style("added").green()
),
DiffKind::Modified => println!(
" {} {} {}",
style("~").yellow(),
entry.path.display(),
style("modified").yellow()
),
DiffKind::Removed => println!(
" {} {} {}",
style("-").red(),
entry.path.display(),
style("removed").red()
),
DiffKind::Drift => println!(
" {} {} {}",
style("!").red().bold(),
entry.path.display(),
style("drift (overwriting via --force)").red().bold()
),
}
}
for add in &plan.diff.env_additions {
println!(
" {} env: {}={} {}",
style("+").green().bold(),
add.key,
add.value,
style("appended to .env (registry-added)").green()
);
}
println!(
" {} systemctl --user daemon-reload + restart {} (brief downtime)",
style("→").cyan(),
plan.service
);
if let Some(backup) = &plan.backup_dir {
println!(
" {} backup of replaced files: {}",
style("→").cyan(),
style(backup.display()).dim()
);
println!(
" {} undo with: {}",
style("→").cyan(),
style(format!("ryra revert {}", plan.service)).dim()
);
}
}
println!();
}
fn resolve_targets(services: &[String]) -> Result<Vec<String>> {
if !services.is_empty() {
for s in services {
if !ryra_core::is_service_installed(s) {
anyhow::bail!("service '{s}' is not installed");
}
}
let mut out: Vec<String> = services.to_vec();
let mut seen = BTreeSet::new();
out.retain(|s| seen.insert(s.clone()));
return Ok(out);
}
let installed = ryra_core::list_installed()?;
Ok(installed.into_iter().map(|s| s.name).collect())
}