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