Skip to main content

dsc/commands/
backup.rs

1use crate::api::DiscourseClient;
2use crate::cli::OutputFormat;
3use crate::commands::common::{ensure_api_credentials, select_discourse};
4use crate::config::Config;
5use anyhow::{Context, Result, anyhow};
6use std::fs;
7use std::io;
8use std::path::Path;
9
10pub fn backup_create(config: &Config, discourse_name: &str) -> Result<()> {
11    let discourse = select_discourse(config, Some(discourse_name))?;
12    ensure_api_credentials(discourse)?;
13    let client = DiscourseClient::new(discourse)?;
14    client.create_backup()?;
15    Ok(())
16}
17
18pub fn backup_list(
19    config: &Config,
20    discourse_name: &str,
21    format: OutputFormat,
22    verbose: bool,
23) -> Result<()> {
24    let discourse = select_discourse(config, Some(discourse_name))?;
25    ensure_api_credentials(discourse)?;
26    let client = DiscourseClient::new(discourse)?;
27    let response = client.list_backups()?;
28    let mut backups = response
29        .get("backups")
30        .and_then(|v| v.as_array())
31        .cloned()
32        .unwrap_or_default();
33    backups.sort_by(|a, b| backup_created_at(b).cmp(&backup_created_at(a)));
34    let global_location = backup_location_response(&response);
35    let backup_size = |backup: &serde_json::Value| -> String {
36        backup
37            .get("size")
38            .and_then(|v| v.as_str())
39            .map(|v| v.to_string())
40            .or_else(|| {
41                backup
42                    .get("size_bytes")
43                    .and_then(|v| v.as_u64())
44                    .map(|v| v.to_string())
45            })
46            .unwrap_or_else(|| "unknown".to_string())
47    };
48
49    match format {
50        OutputFormat::Text => {
51            if backups.is_empty() && !verbose {
52                println!("No backups found.");
53                return Ok(());
54            }
55            if let Some(latest) = backups.first() {
56                let filename = backup_filename(latest);
57                let created_at = backup_created_at(latest).unwrap_or("unknown");
58                let location = backup_location(latest, global_location.as_deref());
59                println!(
60                    "Latest backup: {} - {} - {}",
61                    filename, created_at, location
62                );
63            }
64            for backup in &backups {
65                let filename = backup_filename(backup);
66                let created_at = backup_created_at(backup).unwrap_or("unknown");
67                let size = backup_size(backup);
68                let location = backup_location(backup, global_location.as_deref());
69                println!("{} - {} - {} - {}", filename, created_at, size, location);
70            }
71        }
72        OutputFormat::Markdown => {
73            if let Some(latest) = backups.first() {
74                let filename = backup_filename(latest);
75                let created_at = backup_created_at(latest).unwrap_or("unknown");
76                let location = backup_location(latest, global_location.as_deref());
77                println!(
78                    "Latest backup: {} ({}) - {}",
79                    filename, created_at, location
80                );
81            }
82            for backup in &backups {
83                let filename = backup_filename(backup);
84                let created_at = backup_created_at(backup).unwrap_or("unknown");
85                let size = backup_size(backup);
86                let location = backup_location(backup, global_location.as_deref());
87                println!("- {} ({}) - {} - {}", filename, created_at, size, location);
88            }
89        }
90        OutputFormat::MarkdownTable => {
91            println!("| Filename | Created At | Size | Location |");
92            println!("| --- | --- | --- | --- |");
93            for backup in &backups {
94                let filename = backup_filename(backup);
95                let created_at = backup_created_at(backup).unwrap_or("unknown");
96                let size = backup_size(backup);
97                let location = backup_location(backup, global_location.as_deref());
98                println!(
99                    "| {} | {} | {} | {} |",
100                    filename, created_at, size, location
101                );
102            }
103        }
104        OutputFormat::Json => {
105            let raw = serde_json::to_string_pretty(&response)?;
106            println!("{}", raw);
107        }
108        OutputFormat::Yaml => {
109            let raw = serde_yaml::to_string(&response)?;
110            println!("{}", raw);
111        }
112        OutputFormat::Csv => {
113            let mut writer = csv::Writer::from_writer(io::stdout());
114            writer.write_record(["filename", "created_at", "size", "location"])?;
115            for backup in &backups {
116                let filename = backup_filename(backup);
117                let created_at = backup_created_at(backup).unwrap_or("");
118                let size = backup
119                    .get("size")
120                    .and_then(|v| v.as_str())
121                    .map(|v| v.to_string())
122                    .or_else(|| {
123                        backup
124                            .get("size_bytes")
125                            .and_then(|v| v.as_u64())
126                            .map(|v| v.to_string())
127                    })
128                    .unwrap_or_default();
129                let location = backup_location(backup, global_location.as_deref());
130                writer.write_record([filename, created_at, &size, &location])?;
131            }
132            writer.flush()?;
133        }
134        OutputFormat::Urls => {
135            return Err(anyhow!(
136                "'backup list' does not support '--format urls'; use text/markdown/json/yaml/csv"
137            ));
138        }
139    }
140    Ok(())
141}
142
143pub fn backup_restore(
144    config: &Config,
145    discourse_name: &str,
146    backup_path: &str,
147    dry_run: bool,
148) -> Result<()> {
149    let discourse = select_discourse(config, Some(discourse_name))?;
150    ensure_api_credentials(discourse)?;
151    if dry_run {
152        println!(
153            "[dry-run] {}: would restore backup {}",
154            discourse.name, backup_path
155        );
156        return Ok(());
157    }
158    let client = DiscourseClient::new(discourse)?;
159    client.restore_backup(backup_path)?;
160    Ok(())
161}
162
163pub fn backup_pull(
164    config: &Config,
165    discourse_name: &str,
166    backup_filename: &str,
167    local_path: Option<&Path>,
168) -> Result<()> {
169    let discourse = select_discourse(config, Some(discourse_name))?;
170    ensure_api_credentials(discourse)?;
171    let client = DiscourseClient::new(discourse)?;
172
173    let url = format!("{}/admin/backups/{}", client.baseurl(), backup_filename);
174    let response = client.get(&format!("/admin/backups/{}", backup_filename))?;
175    let status = response.status();
176    if !status.is_success() {
177        return Err(anyhow!(
178            "failed to download backup {} (HTTP {})",
179            backup_filename,
180            status
181        ));
182    }
183
184    let dest = match local_path {
185        Some(p) => p.to_path_buf(),
186        None => Path::new(backup_filename).to_path_buf(),
187    };
188    if let Some(parent) = dest.parent() {
189        fs::create_dir_all(parent)
190            .with_context(|| format!("creating directory {}", parent.display()))?;
191    }
192
193    let bytes = response
194        .bytes()
195        .with_context(|| format!("reading backup response from {}", url))?;
196    fs::write(&dest, &bytes).with_context(|| format!("writing {}", dest.display()))?;
197    println!(
198        "Backup {} pulled to {} ({} bytes)",
199        backup_filename,
200        dest.display(),
201        bytes.len()
202    );
203    Ok(())
204}
205
206fn backup_filename(backup: &serde_json::Value) -> &str {
207    backup
208        .get("filename")
209        .and_then(|v| v.as_str())
210        .unwrap_or("unknown")
211}
212
213fn backup_created_at(backup: &serde_json::Value) -> Option<&str> {
214    backup.get("created_at").and_then(|v| v.as_str())
215}
216
217fn backup_location_response(response: &serde_json::Value) -> Option<String> {
218    let keys = [
219        "backup_location",
220        "location",
221        "storage_location",
222        "backup_store",
223        "upload_destination",
224    ];
225    for key in keys {
226        if let Some(value) = response.get(key).and_then(|v| v.as_str()) {
227            let trimmed = value.trim();
228            if !trimmed.is_empty() {
229                return Some(trimmed.to_string());
230            }
231        }
232    }
233    None
234}
235
236fn backup_location(backup: &serde_json::Value, global: Option<&str>) -> String {
237    if let Some(global) = global {
238        return global.to_string();
239    }
240    if let Some(location) = backup
241        .get("location")
242        .and_then(|v| v.as_str())
243        .or_else(|| backup.get("backup_location").and_then(|v| v.as_str()))
244        .or_else(|| backup.get("storage_location").and_then(|v| v.as_str()))
245        .or_else(|| backup.get("upload_destination").and_then(|v| v.as_str()))
246    {
247        return location.to_string();
248    }
249    if let Some(url) = backup
250        .get("url")
251        .and_then(|v| v.as_str())
252        .or_else(|| backup.get("path").and_then(|v| v.as_str()))
253    {
254        return location_from_url(url);
255    }
256    "unknown".to_string()
257}
258
259fn location_from_url(url: &str) -> String {
260    let trimmed = url.trim();
261    if trimmed.starts_with('/') {
262        return "local".to_string();
263    }
264    if let Some(rest) = trimmed.split("//").nth(1) {
265        return rest.split('/').next().unwrap_or(trimmed).to_string();
266    }
267    trimmed.to_string()
268}