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 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 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
205fn 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 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
233fn 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
250fn 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 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 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}