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::{Context, Result, anyhow};
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
39    let mut matched = 0;
40    for discourse in config
41        .discourse
42        .iter()
43        .filter(|d| matches_tag_filter(d, &filter))
44    {
45        matched += 1;
46        ensure_api_credentials(discourse)?;
47        if dry_run {
48            println!(
49                "[dry-run] {}: would set {} = {}",
50                discourse.name, setting, value
51            );
52            continue;
53        }
54        let client = DiscourseClient::new(discourse)?;
55        client.update_site_setting(setting, value)?;
56        println!("{}: updated {}", discourse.name, setting);
57    }
58
59    if matched == 0 {
60        return Err(anyhow!("no discourses matched the tag filter"));
61    }
62
63    Ok(())
64}
65
66/// Get the current value of a single site setting.
67pub fn get_site_setting(
68    config: &Config,
69    discourse_name: &str,
70    setting: &str,
71    format: ListFormat,
72) -> Result<()> {
73    let discourse = select_discourse(config, Some(discourse_name))?;
74    ensure_api_credentials(discourse)?;
75    let client = DiscourseClient::new(discourse)?;
76    let value = client.fetch_site_setting(setting)?;
77    emit_result(
78        format,
79        &serde_json::json!({ "setting": setting, "value": value }),
80        &value,
81    )
82}
83
84/// Does a discourse carry at least one of the tags in `filter`? An empty
85/// filter matches every discourse. Shared by `setting set --tags` and
86/// `setting audit --tags`.
87fn matches_tag_filter(disc: &DiscourseConfig, filter: &[String]) -> bool {
88    if filter.is_empty() {
89        return true;
90    }
91    let Some(disc_tags) = disc.tags.as_ref() else {
92        return false;
93    };
94    let disc_tags: Vec<String> = disc_tags.iter().map(|t| t.to_ascii_lowercase()).collect();
95    filter.iter().any(|tag| {
96        let tag = tag.to_ascii_lowercase();
97        disc_tags.iter().any(|t| t == &tag)
98    })
99}
100
101#[derive(Debug, Serialize)]
102struct AuditRow {
103    discourse: String,
104    #[serde(skip_serializing_if = "Option::is_none")]
105    value: Option<String>,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    error: Option<String>,
108}
109
110/// Show the value of one setting across every configured forum (filtered by
111/// `tags`). One unreachable or unauthenticated forum is reported inline rather
112/// than aborting the whole audit. Distinct from `setting diff`, which compares
113/// two specific sources across all settings.
114pub fn audit_site_setting(
115    config: &Config,
116    setting: &str,
117    tags: Option<&str>,
118    format: ListFormat,
119) -> Result<()> {
120    let filter = tags.map(parse_tags).unwrap_or_default();
121    let rows: Vec<AuditRow> = config
122        .discourse
123        .iter()
124        .filter(|d| matches_tag_filter(d, &filter))
125        .map(|d| match fetch_one_setting(d, setting) {
126            Ok(value) => AuditRow {
127                discourse: d.name.clone(),
128                value: Some(value),
129                error: None,
130            },
131            Err(e) => AuditRow {
132                discourse: d.name.clone(),
133                value: None,
134                error: Some(e.to_string()),
135            },
136        })
137        .collect();
138
139    match format {
140        ListFormat::Text => print!("{}", render_audit_text(setting, &rows)),
141        ListFormat::Json => println!("{}", serde_json::to_string_pretty(&rows)?),
142        ListFormat::Yaml => println!("{}", serde_yaml::to_string(&rows)?),
143    }
144    Ok(())
145}
146
147fn fetch_one_setting(discourse: &DiscourseConfig, setting: &str) -> Result<String> {
148    ensure_api_credentials(discourse)?;
149    let client = DiscourseClient::new(discourse)?;
150    client.fetch_site_setting(setting)
151}
152
153/// Render an audit as an aligned `name  value` table followed by an agreement
154/// summary (all agree / N distinct values / none returned). Pure, so it is
155/// unit-tested without touching the network.
156fn render_audit_text(setting: &str, rows: &[AuditRow]) -> String {
157    if rows.is_empty() {
158        return format!("No forums matched for setting '{}'.\n", setting);
159    }
160    let width = rows.iter().map(|r| r.discourse.len()).max().unwrap_or(0);
161    let mut out = String::new();
162    for row in rows {
163        let cell = match (&row.value, &row.error) {
164            (Some(v), _) => v.clone(),
165            (None, Some(e)) => format!("<error: {}>", e),
166            (None, None) => String::new(),
167        };
168        out.push_str(&format!(
169            "{:width$}  {}\n",
170            row.discourse,
171            cell,
172            width = width
173        ));
174    }
175    let values: Vec<&String> = rows.iter().filter_map(|r| r.value.as_ref()).collect();
176    let distinct: std::collections::BTreeSet<&str> = values.iter().map(|v| v.as_str()).collect();
177    let summary = match (values.len(), distinct.len()) {
178        (0, _) => format!("no forum returned a value for '{}'", setting),
179        (n, 1) => format!("all {} forum(s) agree on '{}'", n, setting),
180        (n, d) => format!(
181            "{} distinct values for '{}' across {} forum(s)",
182            d, setting, n
183        ),
184    };
185    out.push_str(&format!("\n{}\n", summary));
186    out
187}
188
189#[derive(Debug, Serialize)]
190struct SettingEntry {
191    setting: String,
192    value: String,
193    category: String,
194}
195
196/// List all site settings.
197pub fn list_site_settings(
198    config: &Config,
199    discourse_name: &str,
200    format: ListFormat,
201    verbose: bool,
202) -> Result<()> {
203    let discourse = select_discourse(config, Some(discourse_name))?;
204    ensure_api_credentials(discourse)?;
205    let client = DiscourseClient::new(discourse)?;
206    let raw = client.list_site_settings()?;
207
208    let settings_arr = raw
209        .get("site_settings")
210        .and_then(|v| v.as_array())
211        .cloned()
212        .unwrap_or_default();
213
214    let entries: Vec<SettingEntry> = settings_arr
215        .into_iter()
216        .map(|entry| {
217            let setting = entry
218                .get("setting")
219                .and_then(|v| v.as_str())
220                .unwrap_or("")
221                .to_string();
222            let value = match entry
223                .get("value")
224                .cloned()
225                .unwrap_or(serde_json::Value::Null)
226            {
227                serde_json::Value::String(s) => s,
228                serde_json::Value::Null => String::new(),
229                other => other.to_string(),
230            };
231            let category = entry
232                .get("category")
233                .and_then(|v| v.as_str())
234                .unwrap_or("uncategorized")
235                .to_string();
236            SettingEntry {
237                setting,
238                value,
239                category,
240            }
241        })
242        .collect();
243
244    match format {
245        ListFormat::Text => {
246            if entries.is_empty() && !verbose {
247                println!("No settings found.");
248                return Ok(());
249            }
250            for e in &entries {
251                println!("{} = {}", e.setting, e.value);
252            }
253        }
254        ListFormat::Json => {
255            println!("{}", serde_json::to_string_pretty(&entries)?);
256        }
257        ListFormat::Yaml => {
258            print!("{}", serde_yaml::to_string(&entries)?);
259        }
260    }
261
262    Ok(())
263}
264
265// ─── Pull (snapshot) ──────────────────────────────────────────────────────────
266
267/// On-disk settings snapshot file (schema version 1).
268///
269/// Spec: `spec/setting-sync.md`. The file is self-documenting: it carries
270/// each setting's default, type, category, and description so that a human
271/// (or LLM) reading the file can understand each entry without consulting
272/// the API. On `push`, only `name` and `value` are honoured.
273#[derive(Debug, Serialize, Deserialize, Clone)]
274pub struct SettingsFile {
275    pub version: u32,
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub discourse_version: Option<String>,
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub pulled_at: Option<String>,
280    #[serde(default)]
281    pub settings: Vec<SettingsEntry>,
282}
283
284#[derive(Debug, Serialize, Deserialize, Clone)]
285pub struct SettingsEntry {
286    pub name: String,
287    pub value: serde_json::Value,
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub default: Option<serde_json::Value>,
290    /// Renamed to avoid the Rust keyword. Serializes as `type`.
291    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
292    pub setting_type: Option<String>,
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub category: Option<String>,
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub description: Option<String>,
297}
298
299/// Settings excluded from `pull` because they are computed/read-only on the
300/// server. Keep the list small; unknown read-only settings are handled
301/// gracefully by `push` (server returns 422, we warn and continue).
302const READONLY_SETTINGS: &[&str] = &[];
303
304/// Pull all site settings to a local file.
305pub fn pull_settings(
306    config: &Config,
307    discourse_name: &str,
308    local_path: &Path,
309    changed_only: bool,
310    category: Option<&str>,
311) -> Result<()> {
312    let discourse = select_discourse(config, Some(discourse_name))?;
313    ensure_api_credentials(discourse)?;
314    let client = DiscourseClient::new(discourse)?;
315
316    let server = client.list_site_settings_detailed()?;
317    let discourse_version = client.fetch_version().ok().flatten();
318
319    let mut entries: Vec<SettingsEntry> = server
320        .into_iter()
321        .filter(|s| !READONLY_SETTINGS.contains(&s.setting.as_str()))
322        .filter(|s| match category {
323            Some(cat) => s.category.eq_ignore_ascii_case(cat),
324            None => true,
325        })
326        .filter(|s| {
327            if !changed_only {
328                return true;
329            }
330            !values_equal(&s.value, &s.default)
331        })
332        .map(detail_to_entry)
333        .collect();
334
335    // Sort by category, then by name for stable diffs.
336    entries.sort_by(|a, b| {
337        let ca = a.category.as_deref().unwrap_or("");
338        let cb = b.category.as_deref().unwrap_or("");
339        ca.cmp(cb).then_with(|| a.name.cmp(&b.name))
340    });
341
342    let pulled_at = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
343
344    let file = SettingsFile {
345        version: 1,
346        discourse_version,
347        pulled_at: Some(pulled_at),
348        settings: entries,
349    };
350
351    let content = if is_json_path(local_path) {
352        serde_json::to_string_pretty(&file).context("serializing settings as JSON")?
353    } else {
354        serde_yaml::to_string(&file).context("serializing settings as YAML")?
355    };
356
357    fs::write(local_path, &content).with_context(|| format!("writing {}", local_path.display()))?;
358
359    println!(
360        "Wrote {} setting{} to {}",
361        file.settings.len(),
362        if file.settings.len() == 1 { "" } else { "s" },
363        local_path.display()
364    );
365    Ok(())
366}
367
368fn detail_to_entry(d: SiteSettingDetail) -> SettingsEntry {
369    SettingsEntry {
370        name: d.setting,
371        value: d.value,
372        default: if d.default.is_null() {
373            None
374        } else {
375            Some(d.default)
376        },
377        setting_type: empty_to_none(d.setting_type),
378        category: empty_to_none(d.category),
379        description: empty_to_none(d.description),
380    }
381}
382
383fn empty_to_none(s: String) -> Option<String> {
384    if s.is_empty() { None } else { Some(s) }
385}
386
387fn values_equal(a: &serde_json::Value, b: &serde_json::Value) -> bool {
388    // Discourse returns numeric and boolean settings as JSON values; comparing
389    // the parsed Value handles all simple types. For string-typed values that
390    // happen to differ only in whitespace, leave the strict comparison; users
391    // who hit edge cases can edit the snapshot directly.
392    a == b
393}
394
395fn is_json_path(p: &Path) -> bool {
396    p.extension()
397        .and_then(|e| e.to_str())
398        .map(|e| e.eq_ignore_ascii_case("json"))
399        .unwrap_or(false)
400}
401
402// ─── Push (apply) ─────────────────────────────────────────────────────────────
403
404/// Apply a settings snapshot file to a Discourse.
405///
406/// Idempotent: only PUTs values that differ from the server. Settings present
407/// in the file but unknown on the server are skipped with a warning. With
408/// `--reset-unlisted`, settings present on the server but absent from the
409/// file are reset to their `default` value.
410pub fn push_settings(
411    config: &Config,
412    discourse_name: &str,
413    local_path: &Path,
414    reset_unlisted: bool,
415    dry_run: bool,
416) -> Result<()> {
417    let discourse = select_discourse(config, Some(discourse_name))?;
418    ensure_api_credentials(discourse)?;
419    let client = DiscourseClient::new(discourse)?;
420
421    let raw = fs::read_to_string(local_path)
422        .with_context(|| format!("reading {}", local_path.display()))?;
423    let file: SettingsFile = if is_json_path(local_path) {
424        serde_json::from_str(&raw).context("parsing settings file as JSON")?
425    } else {
426        serde_yaml::from_str(&raw).context("parsing settings file as YAML")?
427    };
428
429    if file.version != 1 {
430        return Err(anyhow!(
431            "unsupported settings file schema version {} (expected 1)",
432            file.version
433        ));
434    }
435
436    let server = client.list_site_settings_detailed()?;
437    let server_by_name: std::collections::HashMap<&str, &SiteSettingDetail> =
438        server.iter().map(|s| (s.setting.as_str(), s)).collect();
439
440    let mut plan: Vec<PushAction> = Vec::new();
441
442    // File → server: change or unchanged.
443    for entry in &file.settings {
444        let Some(srv) = server_by_name.get(entry.name.as_str()) else {
445            plan.push(PushAction::UnknownOnServer(entry.name.clone()));
446            continue;
447        };
448        let desired = value_to_send_string(&entry.value);
449        let current = value_to_send_string(&srv.value);
450        if desired == current {
451            plan.push(PushAction::Unchanged(entry.name.clone()));
452        } else {
453            plan.push(PushAction::Change {
454                name: entry.name.clone(),
455                from: current,
456                to: desired,
457            });
458        }
459    }
460
461    // Server → file: reset_unlisted.
462    if reset_unlisted {
463        let in_file: std::collections::HashSet<&str> =
464            file.settings.iter().map(|e| e.name.as_str()).collect();
465        for srv in &server {
466            if in_file.contains(srv.setting.as_str()) {
467                continue;
468            }
469            if READONLY_SETTINGS.contains(&srv.setting.as_str()) {
470                continue;
471            }
472            let current = value_to_send_string(&srv.value);
473            let default = value_to_send_string(&srv.default);
474            if current == default {
475                continue;
476            }
477            plan.push(PushAction::Reset {
478                name: srv.setting.clone(),
479                from: current,
480                to: default,
481            });
482        }
483    }
484
485    // Stable order for display.
486    plan.sort_by(|a, b| a.name().cmp(b.name()));
487
488    print_plan(&plan, &discourse.name, dry_run);
489
490    if dry_run {
491        return Ok(());
492    }
493
494    // Apply.
495    let mut applied = 0;
496    let mut failed = 0;
497    for action in &plan {
498        match action {
499            PushAction::Change { name, to, .. } | PushAction::Reset { name, to, .. } => {
500                match client.update_site_setting(name, to) {
501                    Ok(()) => {
502                        applied += 1;
503                    }
504                    Err(err) => {
505                        failed += 1;
506                        eprintln!("  ! {}: failed: {}", name, err);
507                    }
508                }
509            }
510            PushAction::Unchanged(_) | PushAction::UnknownOnServer(_) => {}
511        }
512    }
513
514    println!(
515        "{}: applied {} setting{}{}",
516        discourse.name,
517        applied,
518        if applied == 1 { "" } else { "s" },
519        if failed > 0 {
520            format!(", {} failed", failed)
521        } else {
522            String::new()
523        }
524    );
525    if failed > 0 {
526        return Err(anyhow!("{} setting(s) failed to apply", failed));
527    }
528    Ok(())
529}
530
531#[derive(Debug)]
532enum PushAction {
533    Change {
534        name: String,
535        from: String,
536        to: String,
537    },
538    Reset {
539        name: String,
540        from: String,
541        to: String,
542    },
543    Unchanged(String),
544    UnknownOnServer(String),
545}
546
547impl PushAction {
548    fn name(&self) -> &str {
549        match self {
550            PushAction::Change { name, .. }
551            | PushAction::Reset { name, .. }
552            | PushAction::Unchanged(name)
553            | PushAction::UnknownOnServer(name) => name,
554        }
555    }
556}
557
558fn print_plan(plan: &[PushAction], discourse: &str, dry_run: bool) {
559    let prefix = if dry_run { "[dry-run] " } else { "" };
560    let changes = plan
561        .iter()
562        .filter(|a| matches!(a, PushAction::Change { .. } | PushAction::Reset { .. }))
563        .count();
564    let unchanged = plan
565        .iter()
566        .filter(|a| matches!(a, PushAction::Unchanged(_)))
567        .count();
568    let unknown = plan
569        .iter()
570        .filter(|a| matches!(a, PushAction::UnknownOnServer(_)))
571        .count();
572
573    println!(
574        "{}Setting push plan for {}: {} change{}, {} unchanged, {} unknown",
575        prefix,
576        discourse,
577        changes,
578        if changes == 1 { "" } else { "s" },
579        unchanged,
580        unknown,
581    );
582    for action in plan {
583        match action {
584            PushAction::Change { name, from, to } => {
585                println!("  ~ {}: {} → {}", name, quote(from), quote(to));
586            }
587            PushAction::Reset { name, from, to } => {
588                println!(
589                    "  - {}: {} → {} (reset to default)",
590                    name,
591                    quote(from),
592                    quote(to)
593                );
594            }
595            PushAction::Unchanged(name) => {
596                println!("  = {}: (unchanged)", name);
597            }
598            PushAction::UnknownOnServer(name) => {
599                println!("  ? {}: skipped (not found on server)", name);
600            }
601        }
602    }
603}
604
605fn quote(s: &str) -> String {
606    if s.is_empty() {
607        "\"\"".to_string()
608    } else {
609        format!("\"{}\"", s)
610    }
611}
612
613/// Convert a `serde_json::Value` to the string form expected by Discourse's
614/// `PUT /admin/site_settings/{name}.json` endpoint. Discourse accepts strings
615/// and coerces internally.
616fn value_to_send_string(v: &serde_json::Value) -> String {
617    match v {
618        serde_json::Value::Null => String::new(),
619        serde_json::Value::String(s) => s.clone(),
620        serde_json::Value::Bool(b) => b.to_string(),
621        serde_json::Value::Number(n) => n.to_string(),
622        // Discourse list-type settings are pipe-separated strings on the wire.
623        // If a user wrote a YAML/JSON array, join with "|" for compatibility.
624        serde_json::Value::Array(arr) => arr
625            .iter()
626            .map(value_to_send_string)
627            .collect::<Vec<_>>()
628            .join("|"),
629        serde_json::Value::Object(_) => v.to_string(),
630    }
631}
632
633// ─── Diff (compare two sources) ───────────────────────────────────────────────
634
635/// A canonical, source-agnostic snapshot of settings used by `diff_settings`.
636struct DiffSource {
637    label: String,
638    entries: std::collections::HashMap<String, SettingsEntry>,
639}
640
641/// Compare site settings between two sources. Each source can be a Discourse
642/// name (live fetch) or a path to a snapshot file produced by `pull`.
643pub fn diff_settings(
644    config: &Config,
645    source: &str,
646    target: &str,
647    changed_only: bool,
648    category: Option<&str>,
649    format: ListFormat,
650) -> Result<()> {
651    let a = load_diff_source(config, source)?;
652    let b = load_diff_source(config, target)?;
653
654    // Union of keys.
655    let mut names: std::collections::BTreeSet<String> = a.entries.keys().cloned().collect();
656    names.extend(b.entries.keys().cloned());
657
658    let mut rows: Vec<DiffRow> = Vec::new();
659    for name in names {
660        let ea = a.entries.get(&name);
661        let eb = b.entries.get(&name);
662        let va = ea.map(|e| value_to_send_string(&e.value));
663        let vb = eb.map(|e| value_to_send_string(&e.value));
664        if va == vb {
665            continue;
666        }
667        // Category filter (uses whichever side has metadata).
668        if let Some(cat) = category {
669            let row_cat = ea
670                .and_then(|e| e.category.as_deref())
671                .or_else(|| eb.and_then(|e| e.category.as_deref()))
672                .unwrap_or("");
673            if !row_cat.eq_ignore_ascii_case(cat) {
674                continue;
675            }
676        }
677        // changed-only: filter to rows where at least one side has a value
678        // that differs from its default. Treat an absent setting as "at
679        // default" - this avoids drowning the diff in entries that one side
680        // simply omitted because the snapshot was --changed-only.
681        if changed_only {
682            // Borrow the default from whichever side has metadata.
683            let shared_default = ea
684                .and_then(|e| e.default.as_ref())
685                .or_else(|| eb.and_then(|e| e.default.as_ref()));
686            let a_changed = match ea {
687                Some(e) => shared_default.map(|d| &e.value != d).unwrap_or(true),
688                None => false,
689            };
690            let b_changed = match eb {
691                Some(e) => shared_default.map(|d| &e.value != d).unwrap_or(true),
692                None => false,
693            };
694            if !a_changed && !b_changed {
695                continue;
696            }
697        }
698        rows.push(DiffRow {
699            name,
700            value_a: va,
701            value_b: vb,
702        });
703    }
704
705    print_diff(&rows, &a.label, &b.label, format)
706}
707
708#[derive(Debug, Serialize)]
709struct DiffRow {
710    name: String,
711    #[serde(rename = "a")]
712    value_a: Option<String>,
713    #[serde(rename = "b")]
714    value_b: Option<String>,
715}
716
717/// Resolve a source string to a canonical settings snapshot. Treats the
718/// argument as a file path if it points to an existing file or has a
719/// `.yaml`/`.yml`/`.json` extension; otherwise treats it as a Discourse name.
720fn load_diff_source(config: &Config, src: &str) -> Result<DiffSource> {
721    let path = Path::new(src);
722    let looks_like_file = path.is_file()
723        || matches!(
724            path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase),
725            Some(ref ext) if ext == "yaml" || ext == "yml" || ext == "json"
726        );
727    if looks_like_file {
728        let raw =
729            fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
730        let file: SettingsFile = if is_json_path(path) {
731            serde_json::from_str(&raw).context("parsing settings file as JSON")?
732        } else {
733            serde_yaml::from_str(&raw).context("parsing settings file as YAML")?
734        };
735        let entries: std::collections::HashMap<String, SettingsEntry> = file
736            .settings
737            .into_iter()
738            .map(|e| (e.name.clone(), e))
739            .collect();
740        return Ok(DiffSource {
741            label: path.display().to_string(),
742            entries,
743        });
744    }
745    // Treat as discourse name.
746    let discourse = select_discourse(config, Some(src))?;
747    ensure_api_credentials(discourse)?;
748    let client = DiscourseClient::new(discourse)?;
749    let server = client.list_site_settings_detailed()?;
750    let entries: std::collections::HashMap<String, SettingsEntry> = server
751        .into_iter()
752        .map(|d| {
753            let entry = detail_to_entry(d);
754            (entry.name.clone(), entry)
755        })
756        .collect();
757    Ok(DiffSource {
758        label: discourse.name.clone(),
759        entries,
760    })
761}
762
763fn print_diff(rows: &[DiffRow], label_a: &str, label_b: &str, format: ListFormat) -> Result<()> {
764    match format {
765        ListFormat::Text => {
766            if rows.is_empty() {
767                println!("{} and {}: no differences.", label_a, label_b);
768                return Ok(());
769            }
770            println!(
771                "{} differing setting{} between {} and {}:",
772                rows.len(),
773                if rows.len() == 1 { "" } else { "s" },
774                label_a,
775                label_b
776            );
777            for row in rows {
778                println!("  {}", row.name);
779                println!("    {}: {}", label_a, fmt_diff_value(&row.value_a));
780                println!("    {}: {}", label_b, fmt_diff_value(&row.value_b));
781            }
782        }
783        ListFormat::Json => {
784            let payload = serde_json::json!({
785                "a": label_a,
786                "b": label_b,
787                "differences": rows,
788            });
789            println!("{}", serde_json::to_string_pretty(&payload)?);
790        }
791        ListFormat::Yaml => {
792            let payload = serde_json::json!({
793                "a": label_a,
794                "b": label_b,
795                "differences": rows,
796            });
797            print!("{}", serde_yaml::to_string(&payload)?);
798        }
799    }
800    Ok(())
801}
802
803fn fmt_diff_value(v: &Option<String>) -> String {
804    match v {
805        Some(s) if s.is_empty() => "\"\"".to_string(),
806        Some(s) => format!("\"{}\"", s),
807        None => "(absent)".to_string(),
808    }
809}
810
811#[cfg(test)]
812mod tests {
813    use super::*;
814
815    fn disc(name: &str, tags: Option<Vec<&str>>) -> DiscourseConfig {
816        DiscourseConfig {
817            name: name.to_string(),
818            tags: tags.map(|t| t.into_iter().map(String::from).collect()),
819            ..DiscourseConfig::default()
820        }
821    }
822
823    fn row(name: &str, value: Option<&str>, error: Option<&str>) -> AuditRow {
824        AuditRow {
825            discourse: name.to_string(),
826            value: value.map(String::from),
827            error: error.map(String::from),
828        }
829    }
830
831    #[test]
832    fn tag_filter_empty_matches_all() {
833        assert!(matches_tag_filter(&disc("a", None), &[]));
834        assert!(matches_tag_filter(&disc("a", Some(vec!["prod"])), &[]));
835    }
836
837    #[test]
838    fn tag_filter_matches_case_insensitively() {
839        let filter = vec!["Prod".to_string()];
840        assert!(matches_tag_filter(
841            &disc("a", Some(vec!["prod", "eu"])),
842            &filter
843        ));
844    }
845
846    #[test]
847    fn tag_filter_rejects_untagged_or_nonmatching() {
848        let filter = vec!["prod".to_string()];
849        assert!(!matches_tag_filter(&disc("a", None), &filter));
850        assert!(!matches_tag_filter(
851            &disc("a", Some(vec!["staging"])),
852            &filter
853        ));
854    }
855
856    #[test]
857    fn audit_text_reports_agreement() {
858        let rows = vec![
859            row("forum-a", Some("My Forum"), None),
860            row("forum-b", Some("My Forum"), None),
861        ];
862        let out = render_audit_text("title", &rows);
863        assert!(out.contains("forum-a  My Forum"));
864        assert!(
865            out.contains("all 2 forum(s) agree on 'title'"),
866            "got: {out}"
867        );
868    }
869
870    #[test]
871    fn audit_text_reports_distinct_values() {
872        let rows = vec![row("a", Some("X"), None), row("b", Some("Y"), None)];
873        let out = render_audit_text("title", &rows);
874        assert!(
875            out.contains("2 distinct values for 'title' across 2 forum(s)"),
876            "got: {out}"
877        );
878    }
879
880    #[test]
881    fn audit_text_renders_errors_and_excludes_them_from_agreement() {
882        let rows = vec![
883            row("a", Some("X"), None),
884            row("b", None, Some("auth failed")),
885        ];
886        let out = render_audit_text("title", &rows);
887        assert!(out.contains("<error: auth failed>"));
888        // Only one forum returned a value, so they trivially "agree".
889        assert!(out.contains("all 1 forum(s) agree"), "got: {out}");
890    }
891
892    #[test]
893    fn audit_text_empty_when_no_forums_match() {
894        let out = render_audit_text("title", &[]);
895        assert!(out.contains("No forums matched"));
896    }
897}