use crate::api::DiscourseClient;
use crate::cli::OutputFormat;
use crate::commands::common::{ensure_api_credentials, select_discourse};
use crate::config::Config;
use anyhow::{Context, Result, anyhow};
use std::fs;
use std::io;
use std::path::Path;
pub fn backup_create(config: &Config, discourse_name: &str) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
let client = DiscourseClient::new(discourse)?;
client.create_backup()?;
Ok(())
}
pub fn backup_list(
config: &Config,
discourse_name: &str,
format: OutputFormat,
verbose: bool,
) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
let client = DiscourseClient::new(discourse)?;
let response = client.list_backups()?;
let mut backups = response
.get("backups")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
backups.sort_by(|a, b| backup_created_at(b).cmp(&backup_created_at(a)));
let global_location = backup_location_response(&response);
let backup_size = |backup: &serde_json::Value| -> String {
backup
.get("size")
.and_then(|v| v.as_str())
.map(|v| v.to_string())
.or_else(|| {
backup
.get("size_bytes")
.and_then(|v| v.as_u64())
.map(|v| v.to_string())
})
.unwrap_or_else(|| "unknown".to_string())
};
match format {
OutputFormat::Text => {
if backups.is_empty() && !verbose {
println!("No backups found.");
return Ok(());
}
if let Some(latest) = backups.first() {
let filename = backup_filename(latest);
let created_at = backup_created_at(latest).unwrap_or("unknown");
let location = backup_location(latest, global_location.as_deref());
println!(
"Latest backup: {} - {} - {}",
filename, created_at, location
);
}
for backup in &backups {
let filename = backup_filename(backup);
let created_at = backup_created_at(backup).unwrap_or("unknown");
let size = backup_size(backup);
let location = backup_location(backup, global_location.as_deref());
println!("{} - {} - {} - {}", filename, created_at, size, location);
}
}
OutputFormat::Markdown => {
if let Some(latest) = backups.first() {
let filename = backup_filename(latest);
let created_at = backup_created_at(latest).unwrap_or("unknown");
let location = backup_location(latest, global_location.as_deref());
println!(
"Latest backup: {} ({}) - {}",
filename, created_at, location
);
}
for backup in &backups {
let filename = backup_filename(backup);
let created_at = backup_created_at(backup).unwrap_or("unknown");
let size = backup_size(backup);
let location = backup_location(backup, global_location.as_deref());
println!("- {} ({}) - {} - {}", filename, created_at, size, location);
}
}
OutputFormat::MarkdownTable => {
println!("| Filename | Created At | Size | Location |");
println!("| --- | --- | --- | --- |");
for backup in &backups {
let filename = backup_filename(backup);
let created_at = backup_created_at(backup).unwrap_or("unknown");
let size = backup_size(backup);
let location = backup_location(backup, global_location.as_deref());
println!(
"| {} | {} | {} | {} |",
filename, created_at, size, location
);
}
}
OutputFormat::Json => {
let raw = serde_json::to_string_pretty(&response)?;
println!("{}", raw);
}
OutputFormat::Yaml => {
let raw = serde_yaml::to_string(&response)?;
println!("{}", raw);
}
OutputFormat::Csv => {
let mut writer = csv::Writer::from_writer(io::stdout());
writer.write_record(["filename", "created_at", "size", "location"])?;
for backup in &backups {
let filename = backup_filename(backup);
let created_at = backup_created_at(backup).unwrap_or("");
let size = backup
.get("size")
.and_then(|v| v.as_str())
.map(|v| v.to_string())
.or_else(|| {
backup
.get("size_bytes")
.and_then(|v| v.as_u64())
.map(|v| v.to_string())
})
.unwrap_or_default();
let location = backup_location(backup, global_location.as_deref());
writer.write_record([filename, created_at, &size, &location])?;
}
writer.flush()?;
}
OutputFormat::Urls => {
return Err(anyhow!(
"'backup list' does not support '--format urls'; use text/markdown/json/yaml/csv"
));
}
}
Ok(())
}
pub fn backup_restore(
config: &Config,
discourse_name: &str,
backup_path: &str,
dry_run: bool,
) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
if dry_run {
println!(
"[dry-run] {}: would restore backup {}",
discourse.name, backup_path
);
return Ok(());
}
let client = DiscourseClient::new(discourse)?;
client.restore_backup(backup_path)?;
Ok(())
}
pub fn backup_pull(
config: &Config,
discourse_name: &str,
backup_filename: &str,
local_path: Option<&Path>,
) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
let client = DiscourseClient::new(discourse)?;
let url = format!("{}/admin/backups/{}", client.baseurl(), backup_filename);
let response = client.get(&format!("/admin/backups/{}", backup_filename))?;
let status = response.status();
if !status.is_success() {
return Err(anyhow!(
"failed to download backup {} (HTTP {})",
backup_filename,
status
));
}
let dest = match local_path {
Some(p) => p.to_path_buf(),
None => Path::new(backup_filename).to_path_buf(),
};
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("creating directory {}", parent.display()))?;
}
let bytes = response
.bytes()
.with_context(|| format!("reading backup response from {}", url))?;
fs::write(&dest, &bytes)
.with_context(|| format!("writing {}", dest.display()))?;
println!(
"Backup {} pulled to {} ({} bytes)",
backup_filename,
dest.display(),
bytes.len()
);
Ok(())
}
fn backup_filename(backup: &serde_json::Value) -> &str {
backup
.get("filename")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
}
fn backup_created_at(backup: &serde_json::Value) -> Option<&str> {
backup.get("created_at").and_then(|v| v.as_str())
}
fn backup_location_response(response: &serde_json::Value) -> Option<String> {
let keys = [
"backup_location",
"location",
"storage_location",
"backup_store",
"upload_destination",
];
for key in keys {
if let Some(value) = response.get(key).and_then(|v| v.as_str()) {
let trimmed = value.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
None
}
fn backup_location(backup: &serde_json::Value, global: Option<&str>) -> String {
if let Some(global) = global {
return global.to_string();
}
if let Some(location) = backup
.get("location")
.and_then(|v| v.as_str())
.or_else(|| backup.get("backup_location").and_then(|v| v.as_str()))
.or_else(|| backup.get("storage_location").and_then(|v| v.as_str()))
.or_else(|| backup.get("upload_destination").and_then(|v| v.as_str()))
{
return location.to_string();
}
if let Some(url) = backup
.get("url")
.and_then(|v| v.as_str())
.or_else(|| backup.get("path").and_then(|v| v.as_str()))
{
return location_from_url(url);
}
"unknown".to_string()
}
fn location_from_url(url: &str) -> String {
let trimmed = url.trim();
if trimmed.starts_with('/') {
return "local".to_string();
}
if let Some(rest) = trimmed.split("//").nth(1) {
return rest.split('/').next().unwrap_or(trimmed).to_string();
}
trimmed.to_string()
}