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 = extract_backups(&response);
29    backups.sort_by(|a, b| backup_created_at(b).cmp(&backup_created_at(a)));
30    // The list endpoint doesn't report where backups live; that's the global
31    // `backup_location` site setting (local vs s3). Best-effort and only when
32    // there's something to label - a read failure just blanks the column
33    // rather than failing the listing, and we skip the (heavy) settings fetch
34    // entirely when there are no backups.
35    let global_location = if backups.is_empty() {
36        None
37    } else {
38        client
39            .fetch_site_setting("backup_location")
40            .ok()
41            .map(|v| v.trim().to_string())
42            .filter(|v| !v.is_empty())
43            .or_else(|| backup_location_response(&response))
44    };
45
46    match format {
47        OutputFormat::Text => {
48            if backups.is_empty() && !verbose {
49                println!("No backups found.");
50                return Ok(());
51            }
52            if let Some(latest) = backups.first() {
53                let filename = backup_filename(latest);
54                let created_at = backup_created_at(latest).unwrap_or("unknown");
55                let location = backup_location(latest, global_location.as_deref());
56                println!(
57                    "Latest backup: {} - {} - {}",
58                    filename, created_at, location
59                );
60            }
61            for backup in &backups {
62                let filename = backup_filename(backup);
63                let created_at = backup_created_at(backup).unwrap_or("unknown");
64                let size = backup_size(backup);
65                let location = backup_location(backup, global_location.as_deref());
66                println!("{} - {} - {} - {}", filename, created_at, size, location);
67            }
68        }
69        OutputFormat::Markdown => {
70            if let Some(latest) = backups.first() {
71                let filename = backup_filename(latest);
72                let created_at = backup_created_at(latest).unwrap_or("unknown");
73                let location = backup_location(latest, global_location.as_deref());
74                println!(
75                    "Latest backup: {} ({}) - {}",
76                    filename, created_at, location
77                );
78            }
79            for backup in &backups {
80                let filename = backup_filename(backup);
81                let created_at = backup_created_at(backup).unwrap_or("unknown");
82                let size = backup_size(backup);
83                let location = backup_location(backup, global_location.as_deref());
84                println!("- {} ({}) - {} - {}", filename, created_at, size, location);
85            }
86        }
87        OutputFormat::MarkdownTable => {
88            println!("| Filename | Created At | Size | Location |");
89            println!("| --- | --- | --- | --- |");
90            for backup in &backups {
91                let filename = backup_filename(backup);
92                let created_at = backup_created_at(backup).unwrap_or("unknown");
93                let size = backup_size(backup);
94                let location = backup_location(backup, global_location.as_deref());
95                println!(
96                    "| {} | {} | {} | {} |",
97                    filename, created_at, size, location
98                );
99            }
100        }
101        OutputFormat::Json => {
102            let raw = serde_json::to_string_pretty(&response)?;
103            println!("{}", raw);
104        }
105        OutputFormat::Yaml => {
106            let raw = serde_yaml::to_string(&response)?;
107            println!("{}", raw);
108        }
109        OutputFormat::Csv => {
110            let mut writer = csv::Writer::from_writer(io::stdout());
111            writer.write_record(["filename", "created_at", "size", "location"])?;
112            for backup in &backups {
113                let filename = backup_filename(backup);
114                let created_at = backup_created_at(backup).unwrap_or("");
115                // Raw byte count for machine consumption.
116                let size = backup
117                    .get("size")
118                    .and_then(|v| v.as_u64())
119                    .or_else(|| backup.get("size_bytes").and_then(|v| v.as_u64()))
120                    .map(|v| v.to_string())
121                    .or_else(|| {
122                        backup
123                            .get("size")
124                            .and_then(|v| v.as_str())
125                            .map(|s| s.to_string())
126                    })
127                    .unwrap_or_default();
128                let location = backup_location(backup, global_location.as_deref());
129                writer.write_record([filename, created_at, &size, &location])?;
130            }
131            writer.flush()?;
132        }
133        OutputFormat::Urls => {
134            return Err(anyhow!(
135                "'backup list' does not support '--format urls'; use text/markdown/json/yaml/csv"
136            ));
137        }
138    }
139    Ok(())
140}
141
142pub fn backup_restore(
143    config: &Config,
144    discourse_name: &str,
145    backup_path: &str,
146    dry_run: bool,
147) -> Result<()> {
148    let discourse = select_discourse(config, Some(discourse_name))?;
149    ensure_api_credentials(discourse)?;
150    if dry_run {
151        println!(
152            "[dry-run] {}: would restore backup {}",
153            discourse.name, backup_path
154        );
155        return Ok(());
156    }
157    let client = DiscourseClient::new(discourse)?;
158    client.restore_backup(backup_path)?;
159    Ok(())
160}
161
162pub fn backup_pull(
163    config: &Config,
164    discourse_name: &str,
165    backup_filename: &str,
166    local_path: Option<&Path>,
167) -> Result<()> {
168    let discourse = select_discourse(config, Some(discourse_name))?;
169    ensure_api_credentials(discourse)?;
170    let client = DiscourseClient::new(discourse)?;
171
172    let url = format!("{}/admin/backups/{}", client.baseurl(), backup_filename);
173    let response = client.get(&format!("/admin/backups/{}", backup_filename))?;
174    let status = response.status();
175    if !status.is_success() {
176        return Err(anyhow!(
177            "failed to download backup {} (HTTP {})",
178            backup_filename,
179            status
180        ));
181    }
182
183    let dest = match local_path {
184        Some(p) => p.to_path_buf(),
185        None => Path::new(backup_filename).to_path_buf(),
186    };
187    if let Some(parent) = dest.parent() {
188        fs::create_dir_all(parent)
189            .with_context(|| format!("creating directory {}", parent.display()))?;
190    }
191
192    let bytes = response
193        .bytes()
194        .with_context(|| format!("reading backup response from {}", url))?;
195    fs::write(&dest, &bytes).with_context(|| format!("writing {}", dest.display()))?;
196    println!(
197        "Backup {} pulled to {} ({} bytes)",
198        backup_filename,
199        dest.display(),
200        bytes.len()
201    );
202    Ok(())
203}
204
205/// Pull the backup array out of the list response. `GET /admin/backups.json`
206/// renders a bare array of backup files (`render_serialized(store.files,
207/// BackupFileSerializer)`); an earlier assumption of a `{ "backups": [...] }`
208/// wrapper meant the list was always empty against a real forum. Accept both.
209fn extract_backups(response: &serde_json::Value) -> Vec<serde_json::Value> {
210    response
211        .as_array()
212        .or_else(|| response.get("backups").and_then(|v| v.as_array()))
213        .cloned()
214        .unwrap_or_default()
215}
216
217fn backup_filename(backup: &serde_json::Value) -> &str {
218    backup
219        .get("filename")
220        .and_then(|v| v.as_str())
221        .unwrap_or("unknown")
222}
223
224fn backup_created_at(backup: &serde_json::Value) -> Option<&str> {
225    // Discourse's BackupFileSerializer exposes `last_modified`; tolerate a
226    // `created_at` shape too.
227    backup
228        .get("last_modified")
229        .and_then(|v| v.as_str())
230        .or_else(|| backup.get("created_at").and_then(|v| v.as_str()))
231}
232
233/// Human-readable backup size. The serializer gives `size` as an integer byte
234/// count; tolerate a pre-formatted string and a `size_bytes` alias.
235fn backup_size(backup: &serde_json::Value) -> String {
236    if let Some(bytes) = backup
237        .get("size")
238        .and_then(|v| v.as_u64())
239        .or_else(|| backup.get("size_bytes").and_then(|v| v.as_u64()))
240    {
241        return format_bytes(bytes);
242    }
243    backup
244        .get("size")
245        .and_then(|v| v.as_str())
246        .map(|v| v.to_string())
247        .unwrap_or_else(|| "unknown".to_string())
248}
249
250/// Format a byte count as B / KB / MB / GB / TB (base-1024, one decimal place
251/// above a kilobyte).
252fn format_bytes(bytes: u64) -> String {
253    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
254    let mut value = bytes as f64;
255    let mut unit = 0;
256    while value >= 1024.0 && unit < UNITS.len() - 1 {
257        value /= 1024.0;
258        unit += 1;
259    }
260    if unit == 0 {
261        format!("{} {}", bytes, UNITS[unit])
262    } else {
263        format!("{:.1} {}", value, UNITS[unit])
264    }
265}
266
267fn backup_location_response(response: &serde_json::Value) -> Option<String> {
268    let keys = [
269        "backup_location",
270        "location",
271        "storage_location",
272        "backup_store",
273        "upload_destination",
274    ];
275    for key in keys {
276        if let Some(value) = response.get(key).and_then(|v| v.as_str()) {
277            let trimmed = value.trim();
278            if !trimmed.is_empty() {
279                return Some(trimmed.to_string());
280            }
281        }
282    }
283    None
284}
285
286fn backup_location(backup: &serde_json::Value, global: Option<&str>) -> String {
287    if let Some(global) = global {
288        return global.to_string();
289    }
290    if let Some(location) = backup
291        .get("location")
292        .and_then(|v| v.as_str())
293        .or_else(|| backup.get("backup_location").and_then(|v| v.as_str()))
294        .or_else(|| backup.get("storage_location").and_then(|v| v.as_str()))
295        .or_else(|| backup.get("upload_destination").and_then(|v| v.as_str()))
296    {
297        return location.to_string();
298    }
299    if let Some(url) = backup
300        .get("url")
301        .and_then(|v| v.as_str())
302        .or_else(|| backup.get("path").and_then(|v| v.as_str()))
303    {
304        return location_from_url(url);
305    }
306    "unknown".to_string()
307}
308
309fn location_from_url(url: &str) -> String {
310    let trimmed = url.trim();
311    if trimmed.starts_with('/') {
312        return "local".to_string();
313    }
314    if let Some(rest) = trimmed.split("//").nth(1) {
315        return rest.split('/').next().unwrap_or(trimmed).to_string();
316    }
317    trimmed.to_string()
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use serde_json::json;
324
325    // The authoritative shape: `GET /admin/backups.json` returns a bare array
326    // of `{ filename, size, last_modified }` (BackupFileSerializer).
327    fn discourse_response() -> serde_json::Value {
328        json!([
329            {
330                "filename": "accm-2026-06-26-120005-v20260601000000.tar.gz",
331                "size": 2_147_483_648u64,
332                "last_modified": "2026-06-26T12:00:05.000Z"
333            }
334        ])
335    }
336
337    #[test]
338    fn extracts_bare_array_response() {
339        let backups = extract_backups(&discourse_response());
340        assert_eq!(backups.len(), 1, "bare array must yield the backup");
341        let b = &backups[0];
342        assert_eq!(
343            backup_filename(b),
344            "accm-2026-06-26-120005-v20260601000000.tar.gz"
345        );
346        assert_eq!(backup_created_at(b), Some("2026-06-26T12:00:05.000Z"));
347        assert_eq!(backup_size(b), "2.0 GB");
348    }
349
350    #[test]
351    fn extracts_wrapped_array_response() {
352        // Defensive: tolerate a `{ "backups": [...] }` wrapper too.
353        let wrapped = json!({ "backups": discourse_response() });
354        assert_eq!(extract_backups(&wrapped).len(), 1);
355    }
356
357    #[test]
358    fn empty_response_yields_no_backups() {
359        assert!(extract_backups(&json!([])).is_empty());
360        assert!(extract_backups(&json!({})).is_empty());
361    }
362
363    #[test]
364    fn created_at_is_used_when_last_modified_absent() {
365        let b = json!({ "filename": "x.tar.gz", "created_at": "2026-01-01T00:00:00Z" });
366        assert_eq!(backup_created_at(&b), Some("2026-01-01T00:00:00Z"));
367    }
368
369    #[test]
370    fn size_tolerates_string_and_alias() {
371        assert_eq!(backup_size(&json!({ "size_bytes": 1024u64 })), "1.0 KB");
372        assert_eq!(backup_size(&json!({ "size": "42 MB" })), "42 MB");
373        assert_eq!(backup_size(&json!({})), "unknown");
374    }
375
376    #[test]
377    fn format_bytes_scales_units() {
378        assert_eq!(format_bytes(0), "0 B");
379        assert_eq!(format_bytes(512), "512 B");
380        assert_eq!(format_bytes(2048), "2.0 KB");
381        assert_eq!(format_bytes(5 * 1024 * 1024), "5.0 MB");
382        assert_eq!(format_bytes(2_147_483_648), "2.0 GB");
383        assert_eq!(format_bytes(3 * 1024u64.pow(4)), "3.0 TB");
384    }
385}