use crate::api::{DiscourseClient, SiteSettingDetail};
use crate::cli::ListFormat;
use crate::commands::common::{ensure_api_credentials, parse_tags, select_discourse};
use crate::config::{Config, DiscourseConfig};
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
pub fn set_site_setting(
config: &Config,
discourse_name: Option<&str>,
setting: &str,
value: &str,
tags: Option<&str>,
dry_run: bool,
) -> Result<()> {
if let Some(name) = discourse_name {
let discourse = select_discourse(config, Some(name))?;
ensure_api_credentials(discourse)?;
if dry_run {
println!(
"[dry-run] {}: would set {} = {}",
discourse.name, setting, value
);
return Ok(());
}
let client = DiscourseClient::new(discourse)?;
client.update_site_setting(setting, value)?;
println!("{}: updated {}", discourse.name, setting);
return Ok(());
}
let filter = tags.map(parse_tags).unwrap_or_default();
let matches_filter = |disc: &DiscourseConfig| {
if filter.is_empty() {
return true;
}
let disc_tags = disc.tags.as_ref().map(|t| {
t.iter()
.map(|tag| tag.to_ascii_lowercase())
.collect::<Vec<_>>()
});
let Some(disc_tags) = disc_tags else {
return false;
};
filter.iter().any(|tag| {
let tag = tag.to_ascii_lowercase();
disc_tags.iter().any(|t| t == &tag)
})
};
let mut matched = 0;
for discourse in config.discourse.iter().filter(|d| matches_filter(d)) {
matched += 1;
ensure_api_credentials(discourse)?;
if dry_run {
println!(
"[dry-run] {}: would set {} = {}",
discourse.name, setting, value
);
continue;
}
let client = DiscourseClient::new(discourse)?;
client.update_site_setting(setting, value)?;
println!("{}: updated {}", discourse.name, setting);
}
if matched == 0 {
return Err(anyhow!("no discourses matched the tag filter"));
}
Ok(())
}
pub fn get_site_setting(config: &Config, discourse_name: &str, setting: &str) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
let client = DiscourseClient::new(discourse)?;
let value = client.fetch_site_setting(setting)?;
println!("{}", value);
Ok(())
}
#[derive(Debug, Serialize)]
struct SettingEntry {
setting: String,
value: String,
category: String,
}
pub fn list_site_settings(
config: &Config,
discourse_name: &str,
format: ListFormat,
verbose: bool,
) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
let client = DiscourseClient::new(discourse)?;
let raw = client.list_site_settings()?;
let settings_arr = raw
.get("site_settings")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let entries: Vec<SettingEntry> = settings_arr
.into_iter()
.map(|entry| {
let setting = entry
.get("setting")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let value = match entry
.get("value")
.cloned()
.unwrap_or(serde_json::Value::Null)
{
serde_json::Value::String(s) => s,
serde_json::Value::Null => String::new(),
other => other.to_string(),
};
let category = entry
.get("category")
.and_then(|v| v.as_str())
.unwrap_or("uncategorized")
.to_string();
SettingEntry {
setting,
value,
category,
}
})
.collect();
match format {
ListFormat::Text => {
if entries.is_empty() && !verbose {
println!("No settings found.");
return Ok(());
}
for e in &entries {
println!("{} = {}", e.setting, e.value);
}
}
ListFormat::Json => {
println!("{}", serde_json::to_string_pretty(&entries)?);
}
ListFormat::Yaml => {
print!("{}", serde_yaml::to_string(&entries)?);
}
}
Ok(())
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SettingsFile {
pub version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub discourse_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pulled_at: Option<String>,
#[serde(default)]
pub settings: Vec<SettingsEntry>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SettingsEntry {
pub name: String,
pub value: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<serde_json::Value>,
#[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
pub setting_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
const READONLY_SETTINGS: &[&str] = &[];
pub fn pull_settings(
config: &Config,
discourse_name: &str,
local_path: &Path,
changed_only: bool,
category: Option<&str>,
) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
let client = DiscourseClient::new(discourse)?;
let server = client.list_site_settings_detailed()?;
let discourse_version = client.fetch_version().ok().flatten();
let mut entries: Vec<SettingsEntry> = server
.into_iter()
.filter(|s| !READONLY_SETTINGS.contains(&s.setting.as_str()))
.filter(|s| match category {
Some(cat) => s.category.eq_ignore_ascii_case(cat),
None => true,
})
.filter(|s| {
if !changed_only {
return true;
}
!values_equal(&s.value, &s.default)
})
.map(detail_to_entry)
.collect();
entries.sort_by(|a, b| {
let ca = a.category.as_deref().unwrap_or("");
let cb = b.category.as_deref().unwrap_or("");
ca.cmp(cb).then_with(|| a.name.cmp(&b.name))
});
let pulled_at = chrono::Utc::now()
.format("%Y-%m-%dT%H:%M:%SZ")
.to_string();
let file = SettingsFile {
version: 1,
discourse_version,
pulled_at: Some(pulled_at),
settings: entries,
};
let content = if is_json_path(local_path) {
serde_json::to_string_pretty(&file).context("serializing settings as JSON")?
} else {
serde_yaml::to_string(&file).context("serializing settings as YAML")?
};
fs::write(local_path, &content)
.with_context(|| format!("writing {}", local_path.display()))?;
println!(
"Wrote {} setting{} to {}",
file.settings.len(),
if file.settings.len() == 1 { "" } else { "s" },
local_path.display()
);
Ok(())
}
fn detail_to_entry(d: SiteSettingDetail) -> SettingsEntry {
SettingsEntry {
name: d.setting,
value: d.value,
default: if d.default.is_null() {
None
} else {
Some(d.default)
},
setting_type: empty_to_none(d.setting_type),
category: empty_to_none(d.category),
description: empty_to_none(d.description),
}
}
fn empty_to_none(s: String) -> Option<String> {
if s.is_empty() { None } else { Some(s) }
}
fn values_equal(a: &serde_json::Value, b: &serde_json::Value) -> bool {
a == b
}
fn is_json_path(p: &Path) -> bool {
p.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("json"))
.unwrap_or(false)
}
pub fn push_settings(
config: &Config,
discourse_name: &str,
local_path: &Path,
reset_unlisted: bool,
dry_run: bool,
) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
let client = DiscourseClient::new(discourse)?;
let raw = fs::read_to_string(local_path)
.with_context(|| format!("reading {}", local_path.display()))?;
let file: SettingsFile = if is_json_path(local_path) {
serde_json::from_str(&raw).context("parsing settings file as JSON")?
} else {
serde_yaml::from_str(&raw).context("parsing settings file as YAML")?
};
if file.version != 1 {
return Err(anyhow!(
"unsupported settings file schema version {} (expected 1)",
file.version
));
}
let server = client.list_site_settings_detailed()?;
let server_by_name: std::collections::HashMap<&str, &SiteSettingDetail> = server
.iter()
.map(|s| (s.setting.as_str(), s))
.collect();
let mut plan: Vec<PushAction> = Vec::new();
for entry in &file.settings {
let Some(srv) = server_by_name.get(entry.name.as_str()) else {
plan.push(PushAction::UnknownOnServer(entry.name.clone()));
continue;
};
let desired = value_to_send_string(&entry.value);
let current = value_to_send_string(&srv.value);
if desired == current {
plan.push(PushAction::Unchanged(entry.name.clone()));
} else {
plan.push(PushAction::Change {
name: entry.name.clone(),
from: current,
to: desired,
});
}
}
if reset_unlisted {
let in_file: std::collections::HashSet<&str> =
file.settings.iter().map(|e| e.name.as_str()).collect();
for srv in &server {
if in_file.contains(srv.setting.as_str()) {
continue;
}
if READONLY_SETTINGS.contains(&srv.setting.as_str()) {
continue;
}
let current = value_to_send_string(&srv.value);
let default = value_to_send_string(&srv.default);
if current == default {
continue;
}
plan.push(PushAction::Reset {
name: srv.setting.clone(),
from: current,
to: default,
});
}
}
plan.sort_by(|a, b| a.name().cmp(b.name()));
print_plan(&plan, &discourse.name, dry_run);
if dry_run {
return Ok(());
}
let mut applied = 0;
let mut failed = 0;
for action in &plan {
match action {
PushAction::Change { name, to, .. } | PushAction::Reset { name, to, .. } => {
match client.update_site_setting(name, to) {
Ok(()) => {
applied += 1;
}
Err(err) => {
failed += 1;
eprintln!(" ! {}: failed: {}", name, err);
}
}
}
PushAction::Unchanged(_) | PushAction::UnknownOnServer(_) => {}
}
}
println!(
"{}: applied {} setting{}{}",
discourse.name,
applied,
if applied == 1 { "" } else { "s" },
if failed > 0 {
format!(", {} failed", failed)
} else {
String::new()
}
);
if failed > 0 {
return Err(anyhow!("{} setting(s) failed to apply", failed));
}
Ok(())
}
#[derive(Debug)]
enum PushAction {
Change {
name: String,
from: String,
to: String,
},
Reset {
name: String,
from: String,
to: String,
},
Unchanged(String),
UnknownOnServer(String),
}
impl PushAction {
fn name(&self) -> &str {
match self {
PushAction::Change { name, .. }
| PushAction::Reset { name, .. }
| PushAction::Unchanged(name)
| PushAction::UnknownOnServer(name) => name,
}
}
}
fn print_plan(plan: &[PushAction], discourse: &str, dry_run: bool) {
let prefix = if dry_run { "[dry-run] " } else { "" };
let changes = plan
.iter()
.filter(|a| matches!(a, PushAction::Change { .. } | PushAction::Reset { .. }))
.count();
let unchanged = plan
.iter()
.filter(|a| matches!(a, PushAction::Unchanged(_)))
.count();
let unknown = plan
.iter()
.filter(|a| matches!(a, PushAction::UnknownOnServer(_)))
.count();
println!(
"{}Setting push plan for {}: {} change{}, {} unchanged, {} unknown",
prefix,
discourse,
changes,
if changes == 1 { "" } else { "s" },
unchanged,
unknown,
);
for action in plan {
match action {
PushAction::Change { name, from, to } => {
println!(" ~ {}: {} → {}", name, quote(from), quote(to));
}
PushAction::Reset { name, from, to } => {
println!(
" - {}: {} → {} (reset to default)",
name,
quote(from),
quote(to)
);
}
PushAction::Unchanged(name) => {
println!(" = {}: (unchanged)", name);
}
PushAction::UnknownOnServer(name) => {
println!(" ? {}: skipped (not found on server)", name);
}
}
}
}
fn quote(s: &str) -> String {
if s.is_empty() {
"\"\"".to_string()
} else {
format!("\"{}\"", s)
}
}
fn value_to_send_string(v: &serde_json::Value) -> String {
match v {
serde_json::Value::Null => String::new(),
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Array(arr) => arr
.iter()
.map(value_to_send_string)
.collect::<Vec<_>>()
.join("|"),
serde_json::Value::Object(_) => v.to_string(),
}
}
struct DiffSource {
label: String,
entries: std::collections::HashMap<String, SettingsEntry>,
}
pub fn diff_settings(
config: &Config,
source: &str,
target: &str,
changed_only: bool,
category: Option<&str>,
format: ListFormat,
) -> Result<()> {
let a = load_diff_source(config, source)?;
let b = load_diff_source(config, target)?;
let mut names: std::collections::BTreeSet<String> = a.entries.keys().cloned().collect();
names.extend(b.entries.keys().cloned());
let mut rows: Vec<DiffRow> = Vec::new();
for name in names {
let ea = a.entries.get(&name);
let eb = b.entries.get(&name);
let va = ea.map(|e| value_to_send_string(&e.value));
let vb = eb.map(|e| value_to_send_string(&e.value));
if va == vb {
continue;
}
if let Some(cat) = category {
let row_cat = ea
.and_then(|e| e.category.as_deref())
.or_else(|| eb.and_then(|e| e.category.as_deref()))
.unwrap_or("");
if !row_cat.eq_ignore_ascii_case(cat) {
continue;
}
}
if changed_only {
let shared_default = ea
.and_then(|e| e.default.as_ref())
.or_else(|| eb.and_then(|e| e.default.as_ref()));
let a_changed = match ea {
Some(e) => shared_default.map(|d| &e.value != d).unwrap_or(true),
None => false,
};
let b_changed = match eb {
Some(e) => shared_default.map(|d| &e.value != d).unwrap_or(true),
None => false,
};
if !a_changed && !b_changed {
continue;
}
}
rows.push(DiffRow {
name,
value_a: va,
value_b: vb,
});
}
print_diff(&rows, &a.label, &b.label, format)
}
#[derive(Debug, Serialize)]
struct DiffRow {
name: String,
#[serde(rename = "a")]
value_a: Option<String>,
#[serde(rename = "b")]
value_b: Option<String>,
}
fn load_diff_source(config: &Config, src: &str) -> Result<DiffSource> {
let path = Path::new(src);
let looks_like_file = path.is_file()
|| matches!(
path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase),
Some(ref ext) if ext == "yaml" || ext == "yml" || ext == "json"
);
if looks_like_file {
let raw = fs::read_to_string(path)
.with_context(|| format!("reading {}", path.display()))?;
let file: SettingsFile = if is_json_path(path) {
serde_json::from_str(&raw).context("parsing settings file as JSON")?
} else {
serde_yaml::from_str(&raw).context("parsing settings file as YAML")?
};
let entries: std::collections::HashMap<String, SettingsEntry> = file
.settings
.into_iter()
.map(|e| (e.name.clone(), e))
.collect();
return Ok(DiffSource {
label: path.display().to_string(),
entries,
});
}
let discourse = select_discourse(config, Some(src))?;
ensure_api_credentials(discourse)?;
let client = DiscourseClient::new(discourse)?;
let server = client.list_site_settings_detailed()?;
let entries: std::collections::HashMap<String, SettingsEntry> = server
.into_iter()
.map(|d| {
let entry = detail_to_entry(d);
(entry.name.clone(), entry)
})
.collect();
Ok(DiffSource {
label: discourse.name.clone(),
entries,
})
}
fn print_diff(rows: &[DiffRow], label_a: &str, label_b: &str, format: ListFormat) -> Result<()> {
match format {
ListFormat::Text => {
if rows.is_empty() {
println!("{} and {}: no differences.", label_a, label_b);
return Ok(());
}
println!(
"{} differing setting{} between {} and {}:",
rows.len(),
if rows.len() == 1 { "" } else { "s" },
label_a,
label_b
);
for row in rows {
println!(" {}", row.name);
println!(" {}: {}", label_a, fmt_diff_value(&row.value_a));
println!(" {}: {}", label_b, fmt_diff_value(&row.value_b));
}
}
ListFormat::Json => {
let payload = serde_json::json!({
"a": label_a,
"b": label_b,
"differences": rows,
});
println!("{}", serde_json::to_string_pretty(&payload)?);
}
ListFormat::Yaml => {
let payload = serde_json::json!({
"a": label_a,
"b": label_b,
"differences": rows,
});
print!("{}", serde_yaml::to_string(&payload)?);
}
}
Ok(())
}
fn fmt_diff_value(v: &Option<String>) -> String {
match v {
Some(s) if s.is_empty() => "\"\"".to_string(),
Some(s) => format!("\"{}\"", s),
None => "(absent)".to_string(),
}
}