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
10pub fn set_site_setting(
13 config: &Config,
14 discourse_name: Option<&str>,
15 setting: &str,
16 value: &str,
17 tags: Option<&str>,
18 dry_run: bool,
19) -> Result<()> {
20 if let Some(name) = discourse_name {
21 let discourse = select_discourse(config, Some(name))?;
22 ensure_api_credentials(discourse)?;
23 if dry_run {
24 println!(
25 "[dry-run] {}: would set {} = {}",
26 discourse.name, setting, value
27 );
28 return Ok(());
29 }
30 let client = DiscourseClient::new(discourse)?;
31 client.update_site_setting(setting, value)?;
32 println!("{}: updated {}", discourse.name, setting);
33 return Ok(());
34 }
35
36 let filter = tags.map(parse_tags).unwrap_or_default();
38
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
66pub 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
84fn 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
110pub 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
153fn 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
196pub 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#[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 #[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
299const READONLY_SETTINGS: &[&str] = &[];
303
304pub 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 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 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
402pub 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 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 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 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 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
613fn 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 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
633struct DiffSource {
637 label: String,
638 entries: std::collections::HashMap<String, SettingsEntry>,
639}
640
641pub 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 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 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 if changed_only {
682 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
717fn 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 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 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}