Skip to main content

dsc/commands/
setting.rs

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
10/// Set a site setting. If `discourse_name` is given, only that discourse is updated.
11/// Otherwise all discourses matching `tags` are updated.
12pub 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    // No specific discourse - use tag filter across all discourses.
37    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
79/// Get the current value of a single site setting.
80pub 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
96/// List all site settings.
97pub 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// ─── Pull (snapshot) ──────────────────────────────────────────────────────────
166
167/// On-disk settings snapshot file (schema version 1).
168///
169/// Spec: `spec/setting-sync.md`. The file is self-documenting: it carries
170/// each setting's default, type, category, and description so that a human
171/// (or LLM) reading the file can understand each entry without consulting
172/// the API. On `push`, only `name` and `value` are honoured.
173#[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    /// Renamed to avoid the Rust keyword. Serializes as `type`.
191    #[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
199/// Settings excluded from `pull` because they are computed/read-only on the
200/// server. Keep the list small; unknown read-only settings are handled
201/// gracefully by `push` (server returns 422, we warn and continue).
202const READONLY_SETTINGS: &[&str] = &[];
203
204/// Pull all site settings to a local file.
205pub 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    // Sort by category, then by name for stable diffs.
236    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    // Discourse returns numeric and boolean settings as JSON values; comparing
292    // the parsed Value handles all simple types. For string-typed values that
293    // happen to differ only in whitespace, leave the strict comparison; users
294    // who hit edge cases can edit the snapshot directly.
295    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
305// ─── Push (apply) ─────────────────────────────────────────────────────────────
306
307/// Apply a settings snapshot file to a Discourse.
308///
309/// Idempotent: only PUTs values that differ from the server. Settings present
310/// in the file but unknown on the server are skipped with a warning. With
311/// `--reset-unlisted`, settings present on the server but absent from the
312/// file are reset to their `default` value.
313pub 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    // File → server: change or unchanged.
348    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    // Server → file: reset_unlisted.
367    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    // Stable order for display.
391    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    // Apply.
400    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
518/// Convert a `serde_json::Value` to the string form expected by Discourse's
519/// `PUT /admin/site_settings/{name}.json` endpoint. Discourse accepts strings
520/// and coerces internally.
521fn 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        // Discourse list-type settings are pipe-separated strings on the wire.
528        // If a user wrote a YAML/JSON array, join with "|" for compatibility.
529        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
538// ─── Diff (compare two sources) ───────────────────────────────────────────────
539
540/// A canonical, source-agnostic snapshot of settings used by `diff_settings`.
541struct DiffSource {
542    label: String,
543    entries: std::collections::HashMap<String, SettingsEntry>,
544}
545
546/// Compare site settings between two sources. Each source can be a Discourse
547/// name (live fetch) or a path to a snapshot file produced by `pull`.
548pub 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    // Union of keys.
560    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        // Category filter (uses whichever side has metadata).
573        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        // changed-only: filter to rows where at least one side has a value
583        // that differs from its default. Treat an absent setting as "at
584        // default" - this avoids drowning the diff in entries that one side
585        // simply omitted because the snapshot was --changed-only.
586        if changed_only {
587            // Borrow the default from whichever side has metadata.
588            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
622/// Resolve a source string to a canonical settings snapshot. Treats the
623/// argument as a file path if it points to an existing file or has a
624/// `.yaml`/`.yml`/`.json` extension; otherwise treats it as a Discourse name.
625fn 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    // Treat as discourse name.
651    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}