Skip to main content

dsc/commands/
setting.rs

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
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(
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
104/// List all site settings.
105pub 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// ─── Pull (snapshot) ──────────────────────────────────────────────────────────
174
175/// On-disk settings snapshot file (schema version 1).
176///
177/// Spec: `spec/setting-sync.md`. The file is self-documenting: it carries
178/// each setting's default, type, category, and description so that a human
179/// (or LLM) reading the file can understand each entry without consulting
180/// the API. On `push`, only `name` and `value` are honoured.
181#[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    /// Renamed to avoid the Rust keyword. Serializes as `type`.
199    #[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
207/// Settings excluded from `pull` because they are computed/read-only on the
208/// server. Keep the list small; unknown read-only settings are handled
209/// gracefully by `push` (server returns 422, we warn and continue).
210const READONLY_SETTINGS: &[&str] = &[];
211
212/// Pull all site settings to a local file.
213pub 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    // Sort by category, then by name for stable diffs.
244    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    // Discourse returns numeric and boolean settings as JSON values; comparing
300    // the parsed Value handles all simple types. For string-typed values that
301    // happen to differ only in whitespace, leave the strict comparison; users
302    // who hit edge cases can edit the snapshot directly.
303    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
313// ─── Push (apply) ─────────────────────────────────────────────────────────────
314
315/// Apply a settings snapshot file to a Discourse.
316///
317/// Idempotent: only PUTs values that differ from the server. Settings present
318/// in the file but unknown on the server are skipped with a warning. With
319/// `--reset-unlisted`, settings present on the server but absent from the
320/// file are reset to their `default` value.
321pub 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    // File → server: change or unchanged.
356    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    // Server → file: reset_unlisted.
375    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    // Stable order for display.
399    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    // Apply.
408    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
526/// Convert a `serde_json::Value` to the string form expected by Discourse's
527/// `PUT /admin/site_settings/{name}.json` endpoint. Discourse accepts strings
528/// and coerces internally.
529fn 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        // Discourse list-type settings are pipe-separated strings on the wire.
536        // If a user wrote a YAML/JSON array, join with "|" for compatibility.
537        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
546// ─── Diff (compare two sources) ───────────────────────────────────────────────
547
548/// A canonical, source-agnostic snapshot of settings used by `diff_settings`.
549struct DiffSource {
550    label: String,
551    entries: std::collections::HashMap<String, SettingsEntry>,
552}
553
554/// Compare site settings between two sources. Each source can be a Discourse
555/// name (live fetch) or a path to a snapshot file produced by `pull`.
556pub 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    // Union of keys.
568    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        // Category filter (uses whichever side has metadata).
581        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        // changed-only: filter to rows where at least one side has a value
591        // that differs from its default. Treat an absent setting as "at
592        // default" - this avoids drowning the diff in entries that one side
593        // simply omitted because the snapshot was --changed-only.
594        if changed_only {
595            // Borrow the default from whichever side has metadata.
596            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
630/// Resolve a source string to a canonical settings snapshot. Treats the
631/// argument as a file path if it points to an existing file or has a
632/// `.yaml`/`.yml`/`.json` extension; otherwise treats it as a Discourse name.
633fn 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    // Treat as discourse name.
659    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}