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}