1use crate::api::{DiscourseClient, SiteSettingDetail};
2use crate::cli::ListFormat;
3use crate::commands::common::{ensure_api_credentials, parse_tags, select_discourse};
4use crate::config::{Config, DiscourseConfig};
5use anyhow::{anyhow, Context, Result};
6use serde::{Deserialize, Serialize};
7use std::fs;
8use std::path::Path;
9
10pub fn set_site_setting(
13 config: &Config,
14 discourse_name: Option<&str>,
15 setting: &str,
16 value: &str,
17 tags: Option<&str>,
18 dry_run: bool,
19) -> Result<()> {
20 if let Some(name) = discourse_name {
21 let discourse = select_discourse(config, Some(name))?;
22 ensure_api_credentials(discourse)?;
23 if dry_run {
24 println!(
25 "[dry-run] {}: would set {} = {}",
26 discourse.name, setting, value
27 );
28 return Ok(());
29 }
30 let client = DiscourseClient::new(discourse)?;
31 client.update_site_setting(setting, value)?;
32 println!("{}: updated {}", discourse.name, setting);
33 return Ok(());
34 }
35
36 let filter = tags.map(parse_tags).unwrap_or_default();
38 let matches_filter = |disc: &DiscourseConfig| {
39 if filter.is_empty() {
40 return true;
41 }
42 let disc_tags = disc.tags.as_ref().map(|t| {
43 t.iter()
44 .map(|tag| tag.to_ascii_lowercase())
45 .collect::<Vec<_>>()
46 });
47 let Some(disc_tags) = disc_tags else {
48 return false;
49 };
50 filter.iter().any(|tag| {
51 let tag = tag.to_ascii_lowercase();
52 disc_tags.iter().any(|t| t == &tag)
53 })
54 };
55
56 let mut matched = 0;
57 for discourse in config.discourse.iter().filter(|d| matches_filter(d)) {
58 matched += 1;
59 ensure_api_credentials(discourse)?;
60 if dry_run {
61 println!(
62 "[dry-run] {}: would set {} = {}",
63 discourse.name, setting, value
64 );
65 continue;
66 }
67 let client = DiscourseClient::new(discourse)?;
68 client.update_site_setting(setting, value)?;
69 println!("{}: updated {}", discourse.name, setting);
70 }
71
72 if matched == 0 {
73 return Err(anyhow!("no discourses matched the tag filter"));
74 }
75
76 Ok(())
77}
78
79pub fn get_site_setting(config: &Config, discourse_name: &str, setting: &str) -> Result<()> {
81 let discourse = select_discourse(config, Some(discourse_name))?;
82 ensure_api_credentials(discourse)?;
83 let client = DiscourseClient::new(discourse)?;
84 let value = client.fetch_site_setting(setting)?;
85 println!("{}", value);
86 Ok(())
87}
88
89#[derive(Debug, Serialize)]
90struct SettingEntry {
91 setting: String,
92 value: String,
93 category: String,
94}
95
96pub fn list_site_settings(
98 config: &Config,
99 discourse_name: &str,
100 format: ListFormat,
101 verbose: bool,
102) -> Result<()> {
103 let discourse = select_discourse(config, Some(discourse_name))?;
104 ensure_api_credentials(discourse)?;
105 let client = DiscourseClient::new(discourse)?;
106 let raw = client.list_site_settings()?;
107
108 let settings_arr = raw
109 .get("site_settings")
110 .and_then(|v| v.as_array())
111 .cloned()
112 .unwrap_or_default();
113
114 let entries: Vec<SettingEntry> = settings_arr
115 .into_iter()
116 .map(|entry| {
117 let setting = entry
118 .get("setting")
119 .and_then(|v| v.as_str())
120 .unwrap_or("")
121 .to_string();
122 let value = match entry
123 .get("value")
124 .cloned()
125 .unwrap_or(serde_json::Value::Null)
126 {
127 serde_json::Value::String(s) => s,
128 serde_json::Value::Null => String::new(),
129 other => other.to_string(),
130 };
131 let category = entry
132 .get("category")
133 .and_then(|v| v.as_str())
134 .unwrap_or("uncategorized")
135 .to_string();
136 SettingEntry {
137 setting,
138 value,
139 category,
140 }
141 })
142 .collect();
143
144 match format {
145 ListFormat::Text => {
146 if entries.is_empty() && !verbose {
147 println!("No settings found.");
148 return Ok(());
149 }
150 for e in &entries {
151 println!("{} = {}", e.setting, e.value);
152 }
153 }
154 ListFormat::Json => {
155 println!("{}", serde_json::to_string_pretty(&entries)?);
156 }
157 ListFormat::Yaml => {
158 print!("{}", serde_yaml::to_string(&entries)?);
159 }
160 }
161
162 Ok(())
163}
164
165#[derive(Debug, Serialize, Deserialize, Clone)]
174pub struct SettingsFile {
175 pub version: u32,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub discourse_version: Option<String>,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub pulled_at: Option<String>,
180 #[serde(default)]
181 pub settings: Vec<SettingsEntry>,
182}
183
184#[derive(Debug, Serialize, Deserialize, Clone)]
185pub struct SettingsEntry {
186 pub name: String,
187 pub value: serde_json::Value,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub default: Option<serde_json::Value>,
190 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
192 pub setting_type: Option<String>,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub category: Option<String>,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub description: Option<String>,
197}
198
199const READONLY_SETTINGS: &[&str] = &[];
203
204pub fn pull_settings(
206 config: &Config,
207 discourse_name: &str,
208 local_path: &Path,
209 changed_only: bool,
210 category: Option<&str>,
211) -> Result<()> {
212 let discourse = select_discourse(config, Some(discourse_name))?;
213 ensure_api_credentials(discourse)?;
214 let client = DiscourseClient::new(discourse)?;
215
216 let server = client.list_site_settings_detailed()?;
217 let discourse_version = client.fetch_version().ok().flatten();
218
219 let mut entries: Vec<SettingsEntry> = server
220 .into_iter()
221 .filter(|s| !READONLY_SETTINGS.contains(&s.setting.as_str()))
222 .filter(|s| match category {
223 Some(cat) => s.category.eq_ignore_ascii_case(cat),
224 None => true,
225 })
226 .filter(|s| {
227 if !changed_only {
228 return true;
229 }
230 !values_equal(&s.value, &s.default)
231 })
232 .map(detail_to_entry)
233 .collect();
234
235 entries.sort_by(|a, b| {
237 let ca = a.category.as_deref().unwrap_or("");
238 let cb = b.category.as_deref().unwrap_or("");
239 ca.cmp(cb).then_with(|| a.name.cmp(&b.name))
240 });
241
242 let pulled_at = chrono::Utc::now()
243 .format("%Y-%m-%dT%H:%M:%SZ")
244 .to_string();
245
246 let file = SettingsFile {
247 version: 1,
248 discourse_version,
249 pulled_at: Some(pulled_at),
250 settings: entries,
251 };
252
253 let content = if is_json_path(local_path) {
254 serde_json::to_string_pretty(&file).context("serializing settings as JSON")?
255 } else {
256 serde_yaml::to_string(&file).context("serializing settings as YAML")?
257 };
258
259 fs::write(local_path, &content)
260 .with_context(|| format!("writing {}", local_path.display()))?;
261
262 println!(
263 "Wrote {} setting{} to {}",
264 file.settings.len(),
265 if file.settings.len() == 1 { "" } else { "s" },
266 local_path.display()
267 );
268 Ok(())
269}
270
271fn detail_to_entry(d: SiteSettingDetail) -> SettingsEntry {
272 SettingsEntry {
273 name: d.setting,
274 value: d.value,
275 default: if d.default.is_null() {
276 None
277 } else {
278 Some(d.default)
279 },
280 setting_type: empty_to_none(d.setting_type),
281 category: empty_to_none(d.category),
282 description: empty_to_none(d.description),
283 }
284}
285
286fn empty_to_none(s: String) -> Option<String> {
287 if s.is_empty() { None } else { Some(s) }
288}
289
290fn values_equal(a: &serde_json::Value, b: &serde_json::Value) -> bool {
291 a == b
296}
297
298fn is_json_path(p: &Path) -> bool {
299 p.extension()
300 .and_then(|e| e.to_str())
301 .map(|e| e.eq_ignore_ascii_case("json"))
302 .unwrap_or(false)
303}
304
305pub fn push_settings(
314 config: &Config,
315 discourse_name: &str,
316 local_path: &Path,
317 reset_unlisted: bool,
318 dry_run: bool,
319) -> Result<()> {
320 let discourse = select_discourse(config, Some(discourse_name))?;
321 ensure_api_credentials(discourse)?;
322 let client = DiscourseClient::new(discourse)?;
323
324 let raw = fs::read_to_string(local_path)
325 .with_context(|| format!("reading {}", local_path.display()))?;
326 let file: SettingsFile = if is_json_path(local_path) {
327 serde_json::from_str(&raw).context("parsing settings file as JSON")?
328 } else {
329 serde_yaml::from_str(&raw).context("parsing settings file as YAML")?
330 };
331
332 if file.version != 1 {
333 return Err(anyhow!(
334 "unsupported settings file schema version {} (expected 1)",
335 file.version
336 ));
337 }
338
339 let server = client.list_site_settings_detailed()?;
340 let server_by_name: std::collections::HashMap<&str, &SiteSettingDetail> = server
341 .iter()
342 .map(|s| (s.setting.as_str(), s))
343 .collect();
344
345 let mut plan: Vec<PushAction> = Vec::new();
346
347 for entry in &file.settings {
349 let Some(srv) = server_by_name.get(entry.name.as_str()) else {
350 plan.push(PushAction::UnknownOnServer(entry.name.clone()));
351 continue;
352 };
353 let desired = value_to_send_string(&entry.value);
354 let current = value_to_send_string(&srv.value);
355 if desired == current {
356 plan.push(PushAction::Unchanged(entry.name.clone()));
357 } else {
358 plan.push(PushAction::Change {
359 name: entry.name.clone(),
360 from: current,
361 to: desired,
362 });
363 }
364 }
365
366 if reset_unlisted {
368 let in_file: std::collections::HashSet<&str> =
369 file.settings.iter().map(|e| e.name.as_str()).collect();
370 for srv in &server {
371 if in_file.contains(srv.setting.as_str()) {
372 continue;
373 }
374 if READONLY_SETTINGS.contains(&srv.setting.as_str()) {
375 continue;
376 }
377 let current = value_to_send_string(&srv.value);
378 let default = value_to_send_string(&srv.default);
379 if current == default {
380 continue;
381 }
382 plan.push(PushAction::Reset {
383 name: srv.setting.clone(),
384 from: current,
385 to: default,
386 });
387 }
388 }
389
390 plan.sort_by(|a, b| a.name().cmp(b.name()));
392
393 print_plan(&plan, &discourse.name, dry_run);
394
395 if dry_run {
396 return Ok(());
397 }
398
399 let mut applied = 0;
401 let mut failed = 0;
402 for action in &plan {
403 match action {
404 PushAction::Change { name, to, .. } | PushAction::Reset { name, to, .. } => {
405 match client.update_site_setting(name, to) {
406 Ok(()) => {
407 applied += 1;
408 }
409 Err(err) => {
410 failed += 1;
411 eprintln!(" ! {}: failed: {}", name, err);
412 }
413 }
414 }
415 PushAction::Unchanged(_) | PushAction::UnknownOnServer(_) => {}
416 }
417 }
418
419 println!(
420 "{}: applied {} setting{}{}",
421 discourse.name,
422 applied,
423 if applied == 1 { "" } else { "s" },
424 if failed > 0 {
425 format!(", {} failed", failed)
426 } else {
427 String::new()
428 }
429 );
430 if failed > 0 {
431 return Err(anyhow!("{} setting(s) failed to apply", failed));
432 }
433 Ok(())
434}
435
436#[derive(Debug)]
437enum PushAction {
438 Change {
439 name: String,
440 from: String,
441 to: String,
442 },
443 Reset {
444 name: String,
445 from: String,
446 to: String,
447 },
448 Unchanged(String),
449 UnknownOnServer(String),
450}
451
452impl PushAction {
453 fn name(&self) -> &str {
454 match self {
455 PushAction::Change { name, .. }
456 | PushAction::Reset { name, .. }
457 | PushAction::Unchanged(name)
458 | PushAction::UnknownOnServer(name) => name,
459 }
460 }
461}
462
463fn print_plan(plan: &[PushAction], discourse: &str, dry_run: bool) {
464 let prefix = if dry_run { "[dry-run] " } else { "" };
465 let changes = plan
466 .iter()
467 .filter(|a| matches!(a, PushAction::Change { .. } | PushAction::Reset { .. }))
468 .count();
469 let unchanged = plan
470 .iter()
471 .filter(|a| matches!(a, PushAction::Unchanged(_)))
472 .count();
473 let unknown = plan
474 .iter()
475 .filter(|a| matches!(a, PushAction::UnknownOnServer(_)))
476 .count();
477
478 println!(
479 "{}Setting push plan for {}: {} change{}, {} unchanged, {} unknown",
480 prefix,
481 discourse,
482 changes,
483 if changes == 1 { "" } else { "s" },
484 unchanged,
485 unknown,
486 );
487 for action in plan {
488 match action {
489 PushAction::Change { name, from, to } => {
490 println!(" ~ {}: {} → {}", name, quote(from), quote(to));
491 }
492 PushAction::Reset { name, from, to } => {
493 println!(
494 " - {}: {} → {} (reset to default)",
495 name,
496 quote(from),
497 quote(to)
498 );
499 }
500 PushAction::Unchanged(name) => {
501 println!(" = {}: (unchanged)", name);
502 }
503 PushAction::UnknownOnServer(name) => {
504 println!(" ? {}: skipped (not found on server)", name);
505 }
506 }
507 }
508}
509
510fn quote(s: &str) -> String {
511 if s.is_empty() {
512 "\"\"".to_string()
513 } else {
514 format!("\"{}\"", s)
515 }
516}
517
518fn value_to_send_string(v: &serde_json::Value) -> String {
522 match v {
523 serde_json::Value::Null => String::new(),
524 serde_json::Value::String(s) => s.clone(),
525 serde_json::Value::Bool(b) => b.to_string(),
526 serde_json::Value::Number(n) => n.to_string(),
527 serde_json::Value::Array(arr) => arr
530 .iter()
531 .map(value_to_send_string)
532 .collect::<Vec<_>>()
533 .join("|"),
534 serde_json::Value::Object(_) => v.to_string(),
535 }
536}
537
538struct DiffSource {
542 label: String,
543 entries: std::collections::HashMap<String, SettingsEntry>,
544}
545
546pub fn diff_settings(
549 config: &Config,
550 source: &str,
551 target: &str,
552 changed_only: bool,
553 category: Option<&str>,
554 format: ListFormat,
555) -> Result<()> {
556 let a = load_diff_source(config, source)?;
557 let b = load_diff_source(config, target)?;
558
559 let mut names: std::collections::BTreeSet<String> = a.entries.keys().cloned().collect();
561 names.extend(b.entries.keys().cloned());
562
563 let mut rows: Vec<DiffRow> = Vec::new();
564 for name in names {
565 let ea = a.entries.get(&name);
566 let eb = b.entries.get(&name);
567 let va = ea.map(|e| value_to_send_string(&e.value));
568 let vb = eb.map(|e| value_to_send_string(&e.value));
569 if va == vb {
570 continue;
571 }
572 if let Some(cat) = category {
574 let row_cat = ea
575 .and_then(|e| e.category.as_deref())
576 .or_else(|| eb.and_then(|e| e.category.as_deref()))
577 .unwrap_or("");
578 if !row_cat.eq_ignore_ascii_case(cat) {
579 continue;
580 }
581 }
582 if changed_only {
587 let shared_default = ea
589 .and_then(|e| e.default.as_ref())
590 .or_else(|| eb.and_then(|e| e.default.as_ref()));
591 let a_changed = match ea {
592 Some(e) => shared_default.map(|d| &e.value != d).unwrap_or(true),
593 None => false,
594 };
595 let b_changed = match eb {
596 Some(e) => shared_default.map(|d| &e.value != d).unwrap_or(true),
597 None => false,
598 };
599 if !a_changed && !b_changed {
600 continue;
601 }
602 }
603 rows.push(DiffRow {
604 name,
605 value_a: va,
606 value_b: vb,
607 });
608 }
609
610 print_diff(&rows, &a.label, &b.label, format)
611}
612
613#[derive(Debug, Serialize)]
614struct DiffRow {
615 name: String,
616 #[serde(rename = "a")]
617 value_a: Option<String>,
618 #[serde(rename = "b")]
619 value_b: Option<String>,
620}
621
622fn load_diff_source(config: &Config, src: &str) -> Result<DiffSource> {
626 let path = Path::new(src);
627 let looks_like_file = path.is_file()
628 || matches!(
629 path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase),
630 Some(ref ext) if ext == "yaml" || ext == "yml" || ext == "json"
631 );
632 if looks_like_file {
633 let raw = fs::read_to_string(path)
634 .with_context(|| format!("reading {}", path.display()))?;
635 let file: SettingsFile = if is_json_path(path) {
636 serde_json::from_str(&raw).context("parsing settings file as JSON")?
637 } else {
638 serde_yaml::from_str(&raw).context("parsing settings file as YAML")?
639 };
640 let entries: std::collections::HashMap<String, SettingsEntry> = file
641 .settings
642 .into_iter()
643 .map(|e| (e.name.clone(), e))
644 .collect();
645 return Ok(DiffSource {
646 label: path.display().to_string(),
647 entries,
648 });
649 }
650 let discourse = select_discourse(config, Some(src))?;
652 ensure_api_credentials(discourse)?;
653 let client = DiscourseClient::new(discourse)?;
654 let server = client.list_site_settings_detailed()?;
655 let entries: std::collections::HashMap<String, SettingsEntry> = server
656 .into_iter()
657 .map(|d| {
658 let entry = detail_to_entry(d);
659 (entry.name.clone(), entry)
660 })
661 .collect();
662 Ok(DiffSource {
663 label: discourse.name.clone(),
664 entries,
665 })
666}
667
668fn print_diff(rows: &[DiffRow], label_a: &str, label_b: &str, format: ListFormat) -> Result<()> {
669 match format {
670 ListFormat::Text => {
671 if rows.is_empty() {
672 println!("{} and {}: no differences.", label_a, label_b);
673 return Ok(());
674 }
675 println!(
676 "{} differing setting{} between {} and {}:",
677 rows.len(),
678 if rows.len() == 1 { "" } else { "s" },
679 label_a,
680 label_b
681 );
682 for row in rows {
683 println!(" {}", row.name);
684 println!(" {}: {}", label_a, fmt_diff_value(&row.value_a));
685 println!(" {}: {}", label_b, fmt_diff_value(&row.value_b));
686 }
687 }
688 ListFormat::Json => {
689 let payload = serde_json::json!({
690 "a": label_a,
691 "b": label_b,
692 "differences": rows,
693 });
694 println!("{}", serde_json::to_string_pretty(&payload)?);
695 }
696 ListFormat::Yaml => {
697 let payload = serde_json::json!({
698 "a": label_a,
699 "b": label_b,
700 "differences": rows,
701 });
702 print!("{}", serde_yaml::to_string(&payload)?);
703 }
704 }
705 Ok(())
706}
707
708fn fmt_diff_value(v: &Option<String>) -> String {
709 match v {
710 Some(s) if s.is_empty() => "\"\"".to_string(),
711 Some(s) => format!("\"{}\"", s),
712 None => "(absent)".to_string(),
713 }
714}