1use crate::api::DiscourseClient;
2use crate::cli::ListFormat;
3use crate::commands::common::{emit_result, ensure_api_credentials, not_found, select_discourse};
4use crate::commands::update::run_ssh_command;
5use crate::config::{Config, DiscourseConfig};
6use crate::utils::slugify;
7use anyhow::{Context, Result, anyhow};
8use serde::{Deserialize, Serialize};
9use serde_json::{Value, json};
10use std::path::Path;
11
12#[derive(Debug, Serialize)]
13struct ThemeListEntry {
14 id: u64,
15 name: String,
16 status: String,
17}
18
19pub fn theme_list(
20 config: &Config,
21 discourse_name: &str,
22 format: ListFormat,
23 verbose: bool,
24) -> Result<()> {
25 let discourse = select_discourse(config, Some(discourse_name))?;
26 ensure_api_credentials(discourse)?;
27 let client = DiscourseClient::new(discourse)?;
28 let response = client.list_themes()?;
29 let themes = response
30 .get("themes")
31 .and_then(|v| v.as_array())
32 .cloned()
33 .unwrap_or_default();
34 let entries: Vec<ThemeListEntry> = themes
35 .into_iter()
36 .map(|theme| {
37 let id = theme.get("id").and_then(|v| v.as_u64()).unwrap_or_default();
38 let name = theme
39 .get("name")
40 .and_then(|v| v.as_str())
41 .unwrap_or("unknown")
42 .to_string();
43 let status = theme
44 .get("enabled")
45 .and_then(|v| v.as_bool())
46 .map(|value| {
47 if value {
48 "enabled".to_string()
49 } else {
50 "disabled".to_string()
51 }
52 })
53 .unwrap_or_else(|| "unknown".to_string());
54 ThemeListEntry { id, name, status }
55 })
56 .collect();
57
58 match format {
59 ListFormat::Text => {
60 if entries.is_empty() && !verbose {
61 println!("No themes found.");
62 return Ok(());
63 }
64 for theme in entries {
65 println!("{} - {} - {}", theme.id, theme.name, theme.status);
66 }
67 }
68 ListFormat::Json => {
69 let raw = serde_json::to_string_pretty(&entries)?;
70 println!("{}", raw);
71 }
72 ListFormat::Yaml => {
73 let raw = serde_yaml::to_string(&entries)?;
74 println!("{}", raw);
75 }
76 }
77 Ok(())
78}
79
80pub fn theme_install(
84 config: &Config,
85 discourse_name: &str,
86 source: &str,
87 branch: Option<&str>,
88 dry_run: bool,
89) -> Result<()> {
90 let discourse = select_discourse(config, Some(discourse_name))?;
91 ensure_api_credentials(discourse)?;
92 let client = DiscourseClient::new(discourse)?;
93 let remote = looks_like_git_url(source);
94
95 if dry_run {
96 if remote {
97 let branch_note = branch
98 .filter(|b| !b.is_empty())
99 .map(|b| format!(" (branch {})", b))
100 .unwrap_or_default();
101 println!(
102 "[dry-run] {}: would import theme from {}{}",
103 discourse.name,
104 redact_url(source),
105 branch_note
106 );
107 } else {
108 println!(
109 "[dry-run] {}: would import theme from local bundle {}",
110 discourse.name, source
111 );
112 }
113 return Ok(());
114 }
115
116 let result = if remote {
117 client.import_theme_remote(source, branch)?
118 } else {
119 let path = Path::new(source);
120 if !path.is_file() {
121 return Err(anyhow!(
122 "`{}` is neither a git URL nor an existing local bundle file",
123 source
124 ));
125 }
126 client.import_theme_bundle(path)?
127 };
128
129 let theme = extract_theme(&result);
130 let name = theme
131 .get("name")
132 .and_then(|v| v.as_str())
133 .unwrap_or("(unknown)");
134 match theme.get("id").and_then(|v| v.as_u64()) {
135 Some(id) => println!("{}: installed \"{}\" (theme {})", discourse.name, name, id),
136 None => println!("{}: theme import completed", discourse.name),
137 }
138 Ok(())
139}
140
141fn looks_like_git_url(s: &str) -> bool {
143 s.starts_with("http://")
144 || s.starts_with("https://")
145 || s.starts_with("git@")
146 || s.starts_with("ssh://")
147 || s.ends_with(".git")
148}
149
150fn redact_url(url: &str) -> String {
152 if let Some(scheme_end) = url.find("://") {
153 let rest = &url[scheme_end + 3..];
154 if let Some(at) = rest.find('@') {
155 return format!("{}://***@{}", &url[..scheme_end], &rest[at + 1..]);
156 }
157 }
158 url.to_string()
159}
160
161pub fn theme_delete(
163 config: &Config,
164 discourse_name: &str,
165 theme_id: u64,
166 dry_run: bool,
167) -> Result<()> {
168 let discourse = select_discourse(config, Some(discourse_name))?;
169 ensure_api_credentials(discourse)?;
170 let client = DiscourseClient::new(discourse)?;
171 let response = client.fetch_theme(theme_id)?;
172 let theme = extract_theme(&response);
173 let name = theme
174 .get("name")
175 .and_then(|v| v.as_str())
176 .unwrap_or("(unknown)")
177 .to_string();
178 if theme
179 .get("default")
180 .and_then(|v| v.as_bool())
181 .unwrap_or(false)
182 {
183 return Err(anyhow!(
184 "theme {} (\"{}\") is the site default; set another theme as default before deleting it",
185 theme_id,
186 name
187 ));
188 }
189 if dry_run {
190 println!(
191 "[dry-run] {}: would delete theme {} (\"{}\")",
192 discourse.name, theme_id, name
193 );
194 return Ok(());
195 }
196 client.delete_theme(theme_id)?;
197 println!(
198 "{}: deleted theme {} (\"{}\")",
199 discourse.name, theme_id, name
200 );
201 Ok(())
202}
203
204pub fn theme_remove(
205 config: &Config,
206 discourse_name: &str,
207 name: &str,
208 dry_run: bool,
209) -> Result<()> {
210 let discourse = select_discourse(config, Some(discourse_name))?;
211 let target = ssh_target(discourse);
212 let template = std::env::var("DSC_SSH_THEME_REMOVE_CMD")
213 .map_err(|_| {
214 anyhow!(
215 "missing DSC_SSH_THEME_REMOVE_CMD for theme remove; set DSC_SSH_THEME_REMOVE_CMD to your remove command"
216 )
217 })?;
218 let command = render_template(&template, &[("name", name), ("url", name)]);
219 if dry_run {
220 println!("[dry-run] would run on {}: {}", target, command);
221 return Ok(());
222 }
223 let output = run_ssh_command(&target, &command)?;
224 println!("Theme removal completed: {}", name);
225 if !output.trim().is_empty() {
226 println!("{}", output.trim());
227 }
228 Ok(())
229}
230
231pub fn theme_pull(
233 config: &Config,
234 discourse_name: &str,
235 theme_id: u64,
236 local_path: Option<&Path>,
237) -> Result<()> {
238 let discourse = select_discourse(config, Some(discourse_name))?;
239 ensure_api_credentials(discourse)?;
240 let client = DiscourseClient::new(discourse)?;
241 let response = client.fetch_theme(theme_id)?;
242
243 let theme = response.get("theme").unwrap_or(&response);
245
246 let path = match local_path {
247 Some(p) => p.to_path_buf(),
248 None => {
249 let name_slug = theme
250 .get("name")
251 .and_then(|v| v.as_str())
252 .map(slugify)
253 .unwrap_or_else(|| format!("theme-{}", theme_id));
254 let filename = format!("{}.json", name_slug);
255 std::env::current_dir()
256 .context("getting current directory")?
257 .join(filename)
258 }
259 };
260
261 let content = serde_json::to_string_pretty(theme).context("serializing theme to JSON")?;
262 if let Some(parent) = path.parent()
263 && !parent.as_os_str().is_empty()
264 {
265 std::fs::create_dir_all(parent)
266 .with_context(|| format!("creating {}", parent.display()))?;
267 }
268 std::fs::write(&path, content).with_context(|| format!("writing {}", path.display()))?;
269 println!("{}", path.display());
270 Ok(())
271}
272
273pub fn theme_push(
275 config: &Config,
276 discourse_name: &str,
277 json_path: &Path,
278 theme_id: Option<u64>,
279) -> Result<()> {
280 let discourse = select_discourse(config, Some(discourse_name))?;
281 ensure_api_credentials(discourse)?;
282 let client = DiscourseClient::new(discourse)?;
283
284 let raw = std::fs::read_to_string(json_path)
285 .with_context(|| format!("reading {}", json_path.display()))?;
286 let parsed: Value = serde_json::from_str(&raw)
287 .with_context(|| format!("parsing JSON from {}", json_path.display()))?;
288
289 let theme = if let Some(inner) = parsed.get("theme") {
291 inner.clone()
292 } else {
293 parsed
294 };
295
296 let push_data = build_push_payload(&theme);
297
298 let target_id = theme_id.or_else(|| theme.get("id").and_then(|v| v.as_u64()));
299
300 if let Some(id) = target_id {
301 client.update_theme(id, &push_data)?;
302 println!("{}", id);
303 } else {
304 if push_data
305 .get("name")
306 .and_then(|v| v.as_str())
307 .map(|s| s.trim().is_empty())
308 .unwrap_or(true)
309 {
310 return Err(anyhow!(
311 "missing name in theme file; set name or pass a theme ID to update"
312 ));
313 }
314 let new_id = client.create_theme(&push_data)?;
315 println!("{}", new_id);
316 }
317
318 Ok(())
319}
320
321pub fn theme_duplicate(
323 config: &Config,
324 discourse_name: &str,
325 theme_id: u64,
326 format: ListFormat,
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 response = client.fetch_theme(theme_id)?;
333 let theme = response.get("theme").unwrap_or(&response);
334
335 let original_name = theme
336 .get("name")
337 .and_then(|v| v.as_str())
338 .unwrap_or("Unknown");
339 let new_name = format!("Copy of {}", original_name);
340
341 let mut push_data = build_push_payload(theme);
342 push_data["name"] = Value::String(new_name);
343 push_data["default"] = Value::Bool(false);
345
346 let new_id = client.create_theme(&push_data)?;
347 emit_result(format, &json!({ "id": new_id }), &new_id.to_string())
348}
349
350fn build_push_payload(theme: &Value) -> Value {
353 let mut map = serde_json::Map::new();
354 for key in &[
355 "name",
356 "enabled",
357 "user_selectable",
358 "color_scheme_id",
359 "theme_fields",
360 "component",
361 ] {
362 if let Some(val) = theme.get(key) {
363 map.insert(key.to_string(), val.clone());
364 }
365 }
366 Value::Object(map)
367}
368
369fn ssh_target(discourse: &DiscourseConfig) -> String {
370 discourse
371 .ssh_host
372 .clone()
373 .unwrap_or_else(|| discourse.name.clone())
374}
375
376fn render_template(template: &str, replacements: &[(&str, &str)]) -> String {
377 let mut out = template.to_string();
378 for (key, value) in replacements {
379 out = out.replace(&format!("{{{}}}", key), value);
380 }
381 out
382}
383
384#[derive(Debug, Serialize)]
391struct ThemeSettingEntry {
392 setting: String,
393 #[serde(rename = "type")]
394 kind: String,
395 value: Value,
396 default: Value,
397}
398
399fn extract_theme(value: &Value) -> &Value {
402 value.get("theme").unwrap_or(value)
403}
404
405fn value_display(v: &Value) -> String {
408 match v {
409 Value::String(s) => s.clone(),
410 Value::Null => String::new(),
411 other => other.to_string(),
412 }
413}
414
415fn theme_setting_entries(theme: &Value) -> Vec<ThemeSettingEntry> {
416 theme
417 .get("settings")
418 .and_then(|v| v.as_array())
419 .map(|arr| {
420 arr.iter()
421 .map(|s| ThemeSettingEntry {
422 setting: s
423 .get("setting")
424 .and_then(|v| v.as_str())
425 .unwrap_or("")
426 .to_string(),
427 kind: s
428 .get("type")
429 .and_then(|v| v.as_str())
430 .unwrap_or("")
431 .to_string(),
432 value: s.get("value").cloned().unwrap_or(Value::Null),
433 default: s.get("default").cloned().unwrap_or(Value::Null),
434 })
435 .collect()
436 })
437 .unwrap_or_default()
438}
439
440#[derive(Debug, Serialize, Deserialize)]
445struct ThemeSettingsFile {
446 version: u32,
447 #[serde(skip_serializing_if = "Option::is_none", default)]
448 discourse_version: Option<String>,
449 theme_id: u64,
450 #[serde(skip_serializing_if = "Option::is_none", default)]
451 theme_name: Option<String>,
452 #[serde(skip_serializing_if = "Option::is_none", default)]
453 pulled_at: Option<String>,
454 settings: Vec<ThemeSettingsFileEntry>,
455}
456
457#[derive(Debug, Serialize, Deserialize)]
460struct ThemeSettingsFileEntry {
461 setting: String,
462 #[serde(rename = "type", skip_serializing_if = "Option::is_none", default)]
463 kind: Option<String>,
464 value: Value,
465 #[serde(skip_serializing_if = "Option::is_none", default)]
466 default: Option<Value>,
467}
468
469fn expand_json_list(v: &Value) -> Value {
474 if let Value::String(s) = v
475 && matches!(s.trim_start().as_bytes().first(), Some(b'[') | Some(b'{'))
476 && let Ok(parsed) = serde_json::from_str::<Value>(s)
477 && (parsed.is_array() || parsed.is_object())
478 {
479 return parsed;
480 }
481 v.clone()
482}
483
484fn theme_value_to_send(v: &Value) -> String {
490 match v {
491 Value::Null => String::new(),
492 Value::String(s) => s.clone(),
493 other => other.to_string(),
494 }
495}
496
497fn json_equal(a: &str, b: &str) -> bool {
501 match (
502 serde_json::from_str::<Value>(a),
503 serde_json::from_str::<Value>(b),
504 ) {
505 (Ok(va), Ok(vb)) => va == vb,
506 _ => a == b,
507 }
508}
509
510fn describe_change(from: &str, to: &str) -> String {
516 const MAX: usize = 80;
517 let from = normalize_for_display(from);
518 let to = normalize_for_display(to);
519 if from.chars().count() <= MAX && to.chars().count() <= MAX {
520 format!("{} -> {}", from, to)
521 } else {
522 format!("changed ({} -> {} chars)", from.len(), to.len())
523 }
524}
525
526fn normalize_for_display(s: &str) -> String {
529 match serde_json::from_str::<Value>(s) {
530 Ok(v) if v.is_array() || v.is_object() => v.to_string(),
531 _ => s.to_string(),
532 }
533}
534
535fn is_json_path(p: &Path) -> bool {
536 p.extension()
537 .and_then(|e| e.to_str())
538 .map(|e| e.eq_ignore_ascii_case("json"))
539 .unwrap_or(false)
540}
541
542pub fn theme_setting_list(
544 config: &Config,
545 discourse_name: &str,
546 theme_id: u64,
547 format: ListFormat,
548) -> Result<()> {
549 let discourse = select_discourse(config, Some(discourse_name))?;
550 ensure_api_credentials(discourse)?;
551 let client = DiscourseClient::new(discourse)?;
552 let response = client.fetch_theme(theme_id)?;
553 let theme = extract_theme(&response);
554 let entries = theme_setting_entries(theme);
555 match format {
556 ListFormat::Text => {
557 if entries.is_empty() {
558 println!("No settings found for theme {}.", theme_id);
559 return Ok(());
560 }
561 for entry in &entries {
562 println!("{} = {}", entry.setting, value_display(&entry.value));
563 }
564 }
565 ListFormat::Json => println!("{}", serde_json::to_string_pretty(&entries)?),
566 ListFormat::Yaml => println!("{}", serde_yaml::to_string(&entries)?),
567 }
568 Ok(())
569}
570
571pub fn theme_setting_get(
573 config: &Config,
574 discourse_name: &str,
575 theme_id: u64,
576 key: &str,
577 format: ListFormat,
578) -> Result<()> {
579 let discourse = select_discourse(config, Some(discourse_name))?;
580 ensure_api_credentials(discourse)?;
581 let client = DiscourseClient::new(discourse)?;
582 let response = client.fetch_theme(theme_id)?;
583 let theme = extract_theme(&response);
584 let setting = theme
585 .get("settings")
586 .and_then(|v| v.as_array())
587 .and_then(|arr| {
588 arr.iter()
589 .find(|s| s.get("setting").and_then(|v| v.as_str()) == Some(key))
590 })
591 .ok_or_else(|| not_found("theme setting", key))?;
592 let value = setting.get("value").cloned().unwrap_or(Value::Null);
593 emit_result(
594 format,
595 &json!({ "setting": key, "value": value }),
596 &value_display(&value),
597 )
598}
599
600pub fn theme_setting_set(
603 config: &Config,
604 discourse_name: &str,
605 theme_id: u64,
606 key: &str,
607 value: &str,
608 dry_run: bool,
609) -> Result<()> {
610 let discourse = select_discourse(config, Some(discourse_name))?;
611 ensure_api_credentials(discourse)?;
612 let client = DiscourseClient::new(discourse)?;
613 if dry_run {
614 println!(
615 "[dry-run] {}: would set theme {} setting {} = {}",
616 discourse.name, theme_id, key, value
617 );
618 return Ok(());
619 }
620 client.set_theme_setting(theme_id, key, value)?;
621 println!("{}: set theme {} setting {}", discourse.name, theme_id, key);
622 Ok(())
623}
624
625pub fn theme_setting_pull(
632 config: &Config,
633 discourse_name: &str,
634 theme_id: u64,
635 local_path: Option<&Path>,
636) -> Result<()> {
637 let discourse = select_discourse(config, Some(discourse_name))?;
638 ensure_api_credentials(discourse)?;
639 let client = DiscourseClient::new(discourse)?;
640 let response = client.fetch_theme(theme_id)?;
641 let theme = extract_theme(&response);
642 let theme_name = theme
643 .get("name")
644 .and_then(|v| v.as_str())
645 .map(str::to_string);
646
647 let settings: Vec<ThemeSettingsFileEntry> = theme_setting_entries(theme)
648 .into_iter()
649 .map(|e| ThemeSettingsFileEntry {
650 setting: e.setting,
651 kind: if e.kind.is_empty() {
652 None
653 } else {
654 Some(e.kind)
655 },
656 value: expand_json_list(&e.value),
657 default: match &e.default {
658 Value::Null => None,
659 Value::String(s) if s.is_empty() => None,
660 other => Some(expand_json_list(other)),
661 },
662 })
663 .collect();
664
665 let path = match local_path {
666 Some(p) => p.to_path_buf(),
667 None => {
668 let slug = theme_name
669 .as_deref()
670 .map(slugify)
671 .unwrap_or_else(|| format!("theme-{}", theme_id));
672 std::env::current_dir()
673 .context("getting current directory")?
674 .join(format!("{}-settings.yml", slug))
675 }
676 };
677
678 let file = ThemeSettingsFile {
679 version: 1,
680 discourse_version: client.fetch_version().ok().flatten(),
681 theme_id,
682 theme_name,
683 pulled_at: Some(chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()),
684 settings,
685 };
686
687 let content = if is_json_path(&path) {
688 serde_json::to_string_pretty(&file).context("serializing theme settings as JSON")?
689 } else {
690 serde_yaml::to_string(&file).context("serializing theme settings as YAML")?
691 };
692 if let Some(parent) = path.parent()
693 && !parent.as_os_str().is_empty()
694 {
695 std::fs::create_dir_all(parent)
696 .with_context(|| format!("creating {}", parent.display()))?;
697 }
698 std::fs::write(&path, &content).with_context(|| format!("writing {}", path.display()))?;
699
700 let n = file.settings.len();
701 println!(
702 "Wrote {} setting{} to {}",
703 n,
704 if n == 1 { "" } else { "s" },
705 path.display()
706 );
707 Ok(())
708}
709
710pub fn theme_setting_push(
715 config: &Config,
716 discourse_name: &str,
717 theme_id: u64,
718 local_path: &Path,
719 dry_run: bool,
720) -> Result<()> {
721 let discourse = select_discourse(config, Some(discourse_name))?;
722 ensure_api_credentials(discourse)?;
723 let client = DiscourseClient::new(discourse)?;
724
725 let raw = std::fs::read_to_string(local_path)
726 .with_context(|| format!("reading {}", local_path.display()))?;
727 let file: ThemeSettingsFile = if is_json_path(local_path) {
728 serde_json::from_str(&raw).context("parsing theme settings file as JSON")?
729 } else {
730 serde_yaml::from_str(&raw).context("parsing theme settings file as YAML")?
731 };
732 if file.version != 1 {
733 return Err(anyhow!(
734 "unsupported theme settings file schema version {} (expected 1)",
735 file.version
736 ));
737 }
738
739 let response = client.fetch_theme(theme_id)?;
741 let theme = extract_theme(&response);
742 let server = theme_setting_entries(theme);
743 let current_by_name: std::collections::HashMap<&str, &Value> = server
744 .iter()
745 .map(|e| (e.setting.as_str(), &e.value))
746 .collect();
747
748 let mut changes: Vec<(String, String, String)> = Vec::new();
749 let mut unchanged = 0usize;
750 for entry in &file.settings {
751 let desired = theme_value_to_send(&entry.value);
752 match current_by_name.get(entry.setting.as_str()) {
753 None => eprintln!(
754 "warning: setting `{}` not found on theme {}; skipping",
755 entry.setting, theme_id
756 ),
757 Some(current_value) => {
758 let current = theme_value_to_send(current_value);
759 if json_equal(&desired, ¤t) {
760 unchanged += 1;
761 } else {
762 changes.push((entry.setting.clone(), current, desired));
763 }
764 }
765 }
766 }
767
768 if changes.is_empty() {
769 println!(
770 "{}: theme {} already up to date ({} setting{} checked)",
771 discourse.name,
772 theme_id,
773 unchanged,
774 if unchanged == 1 { "" } else { "s" }
775 );
776 return Ok(());
777 }
778
779 if dry_run {
780 println!(
781 "[dry-run] {}: would update {} setting{} on theme {}:",
782 discourse.name,
783 changes.len(),
784 if changes.len() == 1 { "" } else { "s" },
785 theme_id
786 );
787 for (name, from, to) in &changes {
788 println!(" {}: {}", name, describe_change(from, to));
789 }
790 return Ok(());
791 }
792
793 for (name, _from, to) in &changes {
794 client.set_theme_setting(theme_id, name, to)?;
795 println!(" set {}", name);
796 }
797 println!(
798 "{}: updated {} setting{} on theme {}",
799 discourse.name,
800 changes.len(),
801 if changes.len() == 1 { "" } else { "s" },
802 theme_id
803 );
804 Ok(())
805}
806
807#[derive(Debug, Serialize)]
810struct ThemeFieldEntry {
811 field: String,
812 #[serde(rename = "type")]
813 kind: String,
814 bytes: usize,
815 #[serde(skip_serializing_if = "Option::is_none")]
816 upload_url: Option<String>,
817}
818
819fn field_type_label(type_id: i64) -> &'static str {
821 match type_id {
822 0 => "html",
823 1 => "scss",
824 2 => "upload",
825 3 => "yaml",
826 4 => "js",
827 _ => "other",
828 }
829}
830
831fn field_extension(type_id: i64) -> &'static str {
833 match type_id {
834 1 => "scss",
835 0 => "html",
836 3 => "yaml",
837 4 => "js",
838 _ => "txt",
839 }
840}
841
842fn infer_type_id(name: &str) -> i64 {
846 if name.contains("scss") || name == "color_definitions" {
847 1
848 } else if name.ends_with("js") {
849 4
850 } else if name == "yaml" || name == "settings" {
851 3
852 } else {
853 0
854 }
855}
856
857fn split_target_name(spec: &str) -> (String, String) {
860 match spec.split_once('/') {
861 Some((t, n)) => (t.to_string(), n.to_string()),
862 None => (String::new(), spec.to_string()),
863 }
864}
865
866fn find_theme_field<'a>(theme: &'a Value, target: &str, name: &str) -> Option<&'a Value> {
867 theme
868 .get("theme_fields")
869 .and_then(|v| v.as_array())?
870 .iter()
871 .find(|f| {
872 f.get("name").and_then(|v| v.as_str()) == Some(name)
873 && f.get("target").and_then(|v| v.as_str()).unwrap_or("") == target
874 })
875}
876
877fn git_remote_theme(theme: &Value) -> Option<&Value> {
880 let rt = theme.get("remote_theme").filter(|v| !v.is_null())?;
881 rt.get("is_git")
882 .and_then(|v| v.as_bool())
883 .unwrap_or(false)
884 .then_some(rt)
885}
886
887fn short_hash(h: &str) -> String {
888 h.chars().take(8).collect()
889}
890
891fn theme_field_entries(theme: &Value) -> Vec<ThemeFieldEntry> {
892 theme
893 .get("theme_fields")
894 .and_then(|v| v.as_array())
895 .map(|arr| {
896 arr.iter()
897 .filter_map(|f| {
898 let name = f.get("name").and_then(|v| v.as_str())?;
899 let target = f.get("target").and_then(|v| v.as_str()).unwrap_or("");
900 let type_id = f.get("type_id").and_then(|v| v.as_i64()).unwrap_or(-1);
901 let value = f.get("value").and_then(|v| v.as_str()).unwrap_or("");
902 let field = if target.is_empty() {
903 name.to_string()
904 } else {
905 format!("{}/{}", target, name)
906 };
907 Some(ThemeFieldEntry {
908 field,
909 kind: field_type_label(type_id).to_string(),
910 bytes: value.len(),
911 upload_url: f
912 .get("url")
913 .and_then(|v| v.as_str())
914 .filter(|s| !s.is_empty())
915 .map(str::to_string),
916 })
917 })
918 .collect()
919 })
920 .unwrap_or_default()
921}
922
923pub fn theme_field_list(
925 config: &Config,
926 discourse_name: &str,
927 theme_id: u64,
928 format: ListFormat,
929) -> Result<()> {
930 let discourse = select_discourse(config, Some(discourse_name))?;
931 ensure_api_credentials(discourse)?;
932 let client = DiscourseClient::new(discourse)?;
933 let response = client.fetch_theme(theme_id)?;
934 let theme = extract_theme(&response);
935 let entries = theme_field_entries(theme);
936 match format {
937 ListFormat::Text => {
938 if entries.is_empty() {
939 println!("No editable fields for theme {}.", theme_id);
940 return Ok(());
941 }
942 for e in &entries {
943 match &e.upload_url {
944 Some(url) => println!("{} ({}, upload -> {})", e.field, e.kind, url),
945 None => println!("{} ({}, {} bytes)", e.field, e.kind, e.bytes),
946 }
947 }
948 }
949 ListFormat::Json => println!("{}", serde_json::to_string_pretty(&entries)?),
950 ListFormat::Yaml => println!("{}", serde_yaml::to_string(&entries)?),
951 }
952 Ok(())
953}
954
955pub fn theme_field_pull(
957 config: &Config,
958 discourse_name: &str,
959 theme_id: u64,
960 field_spec: &str,
961 local_path: Option<&Path>,
962) -> Result<()> {
963 let (target, name) = split_target_name(field_spec);
964 let discourse = select_discourse(config, Some(discourse_name))?;
965 ensure_api_credentials(discourse)?;
966 let client = DiscourseClient::new(discourse)?;
967 let response = client.fetch_theme(theme_id)?;
968 let theme = extract_theme(&response);
969 let field = find_theme_field(theme, &target, &name).ok_or_else(|| {
970 anyhow!(
971 "theme {} has no field `{}` (see `dsc theme field list {}`)",
972 theme_id,
973 field_spec,
974 discourse_name
975 )
976 })?;
977 let type_id = field.get("type_id").and_then(|v| v.as_i64()).unwrap_or(-1);
978 if type_id == 2 {
979 return Err(anyhow!(
980 "`{}` is an upload var, not a text field; use `dsc theme asset`",
981 field_spec
982 ));
983 }
984 let value = field.get("value").and_then(|v| v.as_str()).unwrap_or("");
985
986 let path = match local_path {
987 Some(p) => p.to_path_buf(),
988 None => {
989 let base = if target.is_empty() {
990 name.clone()
991 } else {
992 format!("{}-{}", target, name)
993 };
994 std::env::current_dir()
995 .context("getting current directory")?
996 .join(format!("{}.{}", base, field_extension(type_id)))
997 }
998 };
999 if let Some(parent) = path.parent()
1000 && !parent.as_os_str().is_empty()
1001 {
1002 std::fs::create_dir_all(parent)
1003 .with_context(|| format!("creating {}", parent.display()))?;
1004 }
1005 std::fs::write(&path, value).with_context(|| format!("writing {}", path.display()))?;
1006 println!(
1007 "Wrote {} ({} bytes) to {}",
1008 field_spec,
1009 value.len(),
1010 path.display()
1011 );
1012 Ok(())
1013}
1014
1015pub fn theme_field_push(
1018 config: &Config,
1019 discourse_name: &str,
1020 theme_id: u64,
1021 field_spec: &str,
1022 local_path: &Path,
1023 dry_run: bool,
1024) -> Result<()> {
1025 let (target, name) = split_target_name(field_spec);
1026 let discourse = select_discourse(config, Some(discourse_name))?;
1027 ensure_api_credentials(discourse)?;
1028 let client = DiscourseClient::new(discourse)?;
1029 let response = client.fetch_theme(theme_id)?;
1030 let theme = extract_theme(&response);
1031
1032 if let Some(rt) = git_remote_theme(theme) {
1033 let url = rt
1034 .get("remote_url")
1035 .and_then(|v| v.as_str())
1036 .unwrap_or("its git repo");
1037 return Err(anyhow!(
1038 "theme {} is a git-backed remote component (from {}); its fields are owned by the \
1039 repo, not the site. Edit upstream and `dsc theme update`, or `dsc theme duplicate` \
1040 it first to get an editable copy.",
1041 theme_id,
1042 url
1043 ));
1044 }
1045
1046 let existing = find_theme_field(theme, &target, &name);
1047 let type_id = existing
1048 .and_then(|f| f.get("type_id").and_then(|v| v.as_i64()))
1049 .unwrap_or_else(|| infer_type_id(&name));
1050 let old_value = existing
1051 .and_then(|f| f.get("value").and_then(|v| v.as_str()))
1052 .unwrap_or("");
1053 let new_value = std::fs::read_to_string(local_path)
1054 .with_context(|| format!("reading {}", local_path.display()))?;
1055
1056 if new_value == old_value {
1057 println!(
1058 "{}: theme {} field {} unchanged",
1059 discourse.name, theme_id, field_spec
1060 );
1061 return Ok(());
1062 }
1063 if dry_run {
1064 let verb = if existing.is_some() {
1065 "update"
1066 } else {
1067 "create"
1068 };
1069 println!(
1070 "[dry-run] {}: would {} theme {} field {} ({} -> {} bytes)",
1071 discourse.name,
1072 verb,
1073 theme_id,
1074 field_spec,
1075 old_value.len(),
1076 new_value.len()
1077 );
1078 return Ok(());
1079 }
1080
1081 let body = json!({
1084 "theme_fields": [{ "target": target, "name": name, "value": new_value, "type_id": type_id }]
1085 });
1086 client.update_theme(theme_id, &body)?;
1087 println!(
1088 "{}: updated theme {} field {} ({} bytes)",
1089 discourse.name,
1090 theme_id,
1091 field_spec,
1092 new_value.len()
1093 );
1094 Ok(())
1095}
1096
1097#[derive(Debug, Serialize)]
1100struct ThemeAssetEntry {
1101 name: String,
1102 #[serde(skip_serializing_if = "Option::is_none")]
1103 filename: Option<String>,
1104 #[serde(skip_serializing_if = "Option::is_none")]
1105 url: Option<String>,
1106}
1107
1108pub fn theme_asset_list(
1110 config: &Config,
1111 discourse_name: &str,
1112 theme_id: u64,
1113 format: ListFormat,
1114) -> Result<()> {
1115 let discourse = select_discourse(config, Some(discourse_name))?;
1116 ensure_api_credentials(discourse)?;
1117 let client = DiscourseClient::new(discourse)?;
1118 let response = client.fetch_theme(theme_id)?;
1119 let theme = extract_theme(&response);
1120 let assets: Vec<ThemeAssetEntry> = theme
1121 .get("theme_fields")
1122 .and_then(|v| v.as_array())
1123 .map(|arr| {
1124 arr.iter()
1125 .filter(|f| f.get("type_id").and_then(|v| v.as_i64()) == Some(2))
1126 .filter_map(|f| {
1127 let name = f.get("name").and_then(|v| v.as_str())?.to_string();
1128 Some(ThemeAssetEntry {
1129 name,
1130 filename: f
1131 .get("filename")
1132 .and_then(|v| v.as_str())
1133 .map(str::to_string),
1134 url: f.get("url").and_then(|v| v.as_str()).map(str::to_string),
1135 })
1136 })
1137 .collect()
1138 })
1139 .unwrap_or_default();
1140 match format {
1141 ListFormat::Text => {
1142 if assets.is_empty() {
1143 println!("No upload assets bound to theme {}.", theme_id);
1144 return Ok(());
1145 }
1146 for a in &assets {
1147 println!(
1148 "${} {} {}",
1149 a.name,
1150 a.filename.as_deref().unwrap_or(""),
1151 a.url.as_deref().unwrap_or("")
1152 );
1153 }
1154 }
1155 ListFormat::Json => println!("{}", serde_json::to_string_pretty(&assets)?),
1156 ListFormat::Yaml => println!("{}", serde_yaml::to_string(&assets)?),
1157 }
1158 Ok(())
1159}
1160
1161pub fn theme_asset_set(
1163 config: &Config,
1164 discourse_name: &str,
1165 theme_id: u64,
1166 var_name: &str,
1167 file: &Path,
1168 dry_run: bool,
1169) -> Result<()> {
1170 let discourse = select_discourse(config, Some(discourse_name))?;
1171 ensure_api_credentials(discourse)?;
1172 let client = DiscourseClient::new(discourse)?;
1173 if dry_run {
1174 println!(
1175 "[dry-run] {}: would upload {} and bind it to theme {} as ${}",
1176 discourse.name,
1177 file.display(),
1178 theme_id,
1179 var_name
1180 );
1181 return Ok(());
1182 }
1183 let info = client.upload_file(file, "theme")?;
1184 let body = json!({
1186 "theme_fields": [{
1187 "target": "common",
1188 "name": var_name,
1189 "type_id": 2,
1190 "upload_id": info.id,
1191 "value": ""
1192 }]
1193 });
1194 client.update_theme(theme_id, &body)?;
1195 println!(
1196 "{}: bound ${} on theme {} -> {} (upload {})",
1197 discourse.name, var_name, theme_id, info.url, info.id
1198 );
1199 Ok(())
1200}
1201
1202pub fn theme_asset_unset(
1205 config: &Config,
1206 discourse_name: &str,
1207 theme_id: u64,
1208 var_name: &str,
1209 dry_run: bool,
1210) -> Result<()> {
1211 let discourse = select_discourse(config, Some(discourse_name))?;
1212 ensure_api_credentials(discourse)?;
1213 let client = DiscourseClient::new(discourse)?;
1214 let response = client.fetch_theme(theme_id)?;
1215 let theme = extract_theme(&response);
1216 if find_theme_field(theme, "common", var_name).is_none() {
1217 return Err(anyhow!(
1218 "theme {} has no asset ${} (see `dsc theme asset list {}`)",
1219 theme_id,
1220 var_name,
1221 discourse_name
1222 ));
1223 }
1224 if dry_run {
1225 println!(
1226 "[dry-run] {}: would unbind ${} from theme {}",
1227 discourse.name, var_name, theme_id
1228 );
1229 return Ok(());
1230 }
1231 let body = json!({
1232 "theme_fields": [{
1233 "target": "common",
1234 "name": var_name,
1235 "type_id": 2,
1236 "value": "",
1237 "upload_id": null
1238 }]
1239 });
1240 client.update_theme(theme_id, &body)?;
1241 println!(
1242 "{}: unbound ${} from theme {}",
1243 discourse.name, var_name, theme_id
1244 );
1245 Ok(())
1246}
1247
1248pub fn theme_update(
1253 config: &Config,
1254 discourse_name: &str,
1255 theme_id: u64,
1256 check: bool,
1257 dry_run: bool,
1258) -> Result<()> {
1259 let discourse = select_discourse(config, Some(discourse_name))?;
1260 ensure_api_credentials(discourse)?;
1261 let client = DiscourseClient::new(discourse)?;
1262 let response = client.fetch_theme(theme_id)?;
1263 let theme = extract_theme(&response);
1264 let rt = git_remote_theme(theme).ok_or_else(|| {
1265 anyhow!(
1266 "theme {} is not a git-backed remote component; nothing to update \
1267 (locally-authored themes have no upstream to pull from)",
1268 theme_id
1269 )
1270 })?;
1271 let remote_url = rt
1272 .get("remote_url")
1273 .and_then(|v| v.as_str())
1274 .unwrap_or("its upstream")
1275 .to_string();
1276 let before = rt
1277 .get("local_version")
1278 .and_then(|v| v.as_str())
1279 .unwrap_or("")
1280 .to_string();
1281
1282 if check || dry_run {
1283 let resp = client.put_theme_flag(theme_id, "remote_check")?;
1285 let behind = git_remote_theme(extract_theme(&resp))
1286 .and_then(|r| r.get("commits_behind").and_then(|v| v.as_i64()))
1287 .unwrap_or(0);
1288 if behind > 0 {
1289 println!(
1290 "{}: theme {} is {} commit{} behind {} (run `dsc theme update {} {}` to pull)",
1291 discourse.name,
1292 theme_id,
1293 behind,
1294 if behind == 1 { "" } else { "s" },
1295 remote_url,
1296 discourse_name,
1297 theme_id
1298 );
1299 } else {
1300 println!(
1301 "{}: theme {} is up to date with {}",
1302 discourse.name, theme_id, remote_url
1303 );
1304 }
1305 return Ok(());
1306 }
1307
1308 let resp = client.put_theme_flag(theme_id, "remote_update")?;
1309 let after = git_remote_theme(extract_theme(&resp))
1310 .and_then(|r| r.get("local_version").and_then(|v| v.as_str()))
1311 .unwrap_or("")
1312 .to_string();
1313 if !after.is_empty() && after != before {
1314 println!(
1315 "{}: updated theme {} {} -> {}",
1316 discourse.name,
1317 theme_id,
1318 short_hash(&before),
1319 short_hash(&after)
1320 );
1321 } else {
1322 println!(
1323 "{}: theme {} already up to date ({})",
1324 discourse.name,
1325 theme_id,
1326 short_hash(&after)
1327 );
1328 }
1329 Ok(())
1330}
1331
1332pub fn theme_set_enabled(
1335 config: &Config,
1336 discourse_name: &str,
1337 theme_id: u64,
1338 enabled: bool,
1339 dry_run: bool,
1340) -> Result<()> {
1341 let discourse = select_discourse(config, Some(discourse_name))?;
1342 ensure_api_credentials(discourse)?;
1343 let client = DiscourseClient::new(discourse)?;
1344 let action = if enabled { "enable" } else { "disable" };
1345 if dry_run {
1346 println!(
1347 "[dry-run] {}: would {} theme {}",
1348 discourse.name, action, theme_id
1349 );
1350 return Ok(());
1351 }
1352 client.update_theme(theme_id, &json!({ "enabled": enabled }))?;
1353 println!("{}: {}d theme {}", discourse.name, action, theme_id);
1354 Ok(())
1355}
1356
1357pub fn theme_set_child(
1361 config: &Config,
1362 discourse_name: &str,
1363 parent_id: u64,
1364 component_id: u64,
1365 attach: bool,
1366 dry_run: bool,
1367) -> Result<()> {
1368 let discourse = select_discourse(config, Some(discourse_name))?;
1369 ensure_api_credentials(discourse)?;
1370 let client = DiscourseClient::new(discourse)?;
1371 let response = client.fetch_theme(parent_id)?;
1372 let theme = extract_theme(&response);
1373 let mut child_ids: Vec<u64> = theme
1374 .get("child_themes")
1375 .and_then(|v| v.as_array())
1376 .map(|arr| {
1377 arr.iter()
1378 .filter_map(|c| c.get("id").and_then(|v| v.as_u64()))
1379 .collect()
1380 })
1381 .unwrap_or_default();
1382
1383 let present = child_ids.contains(&component_id);
1384 if attach && present {
1385 println!(
1386 "{}: component {} already attached to theme {}",
1387 discourse.name, component_id, parent_id
1388 );
1389 return Ok(());
1390 }
1391 if !attach && !present {
1392 println!(
1393 "{}: component {} is not attached to theme {}",
1394 discourse.name, component_id, parent_id
1395 );
1396 return Ok(());
1397 }
1398 if attach {
1399 child_ids.push(component_id);
1400 } else {
1401 child_ids.retain(|&id| id != component_id);
1402 }
1403
1404 let (verb, prep) = if attach {
1405 ("attach", "to")
1406 } else {
1407 ("detach", "from")
1408 };
1409 if dry_run {
1410 println!(
1411 "[dry-run] {}: would {} component {} {} theme {} (child_theme_ids -> {:?})",
1412 discourse.name, verb, component_id, prep, parent_id, child_ids
1413 );
1414 return Ok(());
1415 }
1416 client.update_theme(parent_id, &json!({ "child_theme_ids": child_ids }))?;
1417 println!(
1418 "{}: {}ed component {} {} theme {}",
1419 discourse.name, verb, component_id, prep, parent_id
1420 );
1421 Ok(())
1422}
1423
1424#[derive(Debug, Serialize)]
1425struct ThemeRelation {
1426 id: u64,
1427 name: String,
1428}
1429
1430#[derive(Debug, Serialize)]
1431struct ThemeShow {
1432 id: u64,
1433 name: String,
1434 component: bool,
1435 enabled: bool,
1436 default: bool,
1437 user_selectable: bool,
1438 color_scheme_id: Option<u64>,
1439 parent_themes: Vec<ThemeRelation>,
1440 child_themes: Vec<ThemeRelation>,
1441 settings_count: usize,
1442 fields: Vec<String>,
1443}
1444
1445fn theme_relations(theme: &Value, key: &str) -> Vec<ThemeRelation> {
1448 theme
1449 .get(key)
1450 .and_then(|v| v.as_array())
1451 .map(|arr| {
1452 arr.iter()
1453 .filter_map(|r| {
1454 let id = r.get("id").and_then(|v| v.as_u64())?;
1455 let name = r
1456 .get("name")
1457 .and_then(|v| v.as_str())
1458 .unwrap_or("unknown")
1459 .to_string();
1460 Some(ThemeRelation { id, name })
1461 })
1462 .collect()
1463 })
1464 .unwrap_or_default()
1465}
1466
1467fn theme_field_inventory(theme: &Value) -> Vec<String> {
1471 theme
1472 .get("theme_fields")
1473 .and_then(|v| v.as_array())
1474 .map(|arr| {
1475 arr.iter()
1476 .filter_map(|f| {
1477 let name = f.get("name").and_then(|v| v.as_str())?;
1478 let target = f.get("target").and_then(|v| v.as_str()).unwrap_or("");
1479 if target.is_empty() {
1480 Some(name.to_string())
1481 } else {
1482 Some(format!("{}/{}", target, name))
1483 }
1484 })
1485 .collect()
1486 })
1487 .unwrap_or_default()
1488}
1489
1490fn build_theme_show(theme: &Value, theme_id: u64) -> ThemeShow {
1491 ThemeShow {
1492 id: theme.get("id").and_then(|v| v.as_u64()).unwrap_or(theme_id),
1493 name: theme
1494 .get("name")
1495 .and_then(|v| v.as_str())
1496 .unwrap_or("unknown")
1497 .to_string(),
1498 component: theme
1499 .get("component")
1500 .and_then(|v| v.as_bool())
1501 .unwrap_or(false),
1502 enabled: theme
1503 .get("enabled")
1504 .and_then(|v| v.as_bool())
1505 .unwrap_or(false),
1506 default: theme
1507 .get("default")
1508 .and_then(|v| v.as_bool())
1509 .unwrap_or(false),
1510 user_selectable: theme
1511 .get("user_selectable")
1512 .and_then(|v| v.as_bool())
1513 .unwrap_or(false),
1514 color_scheme_id: theme.get("color_scheme_id").and_then(|v| v.as_u64()),
1515 parent_themes: theme_relations(theme, "parent_themes"),
1516 child_themes: theme_relations(theme, "child_themes"),
1517 settings_count: theme_setting_entries(theme).len(),
1518 fields: theme_field_inventory(theme),
1519 }
1520}
1521
1522fn format_relations(rels: &[ThemeRelation]) -> String {
1523 if rels.is_empty() {
1524 "(none)".to_string()
1525 } else {
1526 rels.iter()
1527 .map(|r| format!("{} - {}", r.id, r.name))
1528 .collect::<Vec<_>>()
1529 .join(", ")
1530 }
1531}
1532
1533pub fn theme_show(
1537 config: &Config,
1538 discourse_name: &str,
1539 theme_id: u64,
1540 format: ListFormat,
1541) -> Result<()> {
1542 let discourse = select_discourse(config, Some(discourse_name))?;
1543 ensure_api_credentials(discourse)?;
1544 let client = DiscourseClient::new(discourse)?;
1545 let response = client.fetch_theme(theme_id)?;
1546 let theme = extract_theme(&response);
1547 let show = build_theme_show(theme, theme_id);
1548 match format {
1549 ListFormat::Json => println!("{}", serde_json::to_string_pretty(&show)?),
1550 ListFormat::Yaml => println!("{}", serde_yaml::to_string(&show)?),
1551 ListFormat::Text => {
1552 println!("{} - {}", show.id, show.name);
1553 println!(
1554 " type: {}",
1555 if show.component { "component" } else { "theme" }
1556 );
1557 println!(" enabled: {}", show.enabled);
1558 println!(" default: {}", show.default);
1559 println!(" user-selectable: {}", show.user_selectable);
1560 if let Some(cs) = show.color_scheme_id {
1561 println!(" color scheme: {}", cs);
1562 }
1563 println!(
1564 " parents: {}",
1565 format_relations(&show.parent_themes)
1566 );
1567 println!(
1568 " children: {}",
1569 format_relations(&show.child_themes)
1570 );
1571 println!(" settings: {}", show.settings_count);
1572 let fields = if show.fields.is_empty() {
1573 "(none)".to_string()
1574 } else {
1575 show.fields.join(", ")
1576 };
1577 println!(" fields: {}", fields);
1578 }
1579 }
1580 Ok(())
1581}
1582
1583#[cfg(test)]
1584mod tests {
1585 use super::*;
1586
1587 #[test]
1588 fn extract_theme_unwraps_envelope_and_passes_bare() {
1589 let wrapped = json!({ "theme": { "id": 11, "name": "kitchen" } });
1590 assert_eq!(
1591 extract_theme(&wrapped).get("id").and_then(|v| v.as_u64()),
1592 Some(11)
1593 );
1594 let bare = json!({ "id": 7, "name": "bare" });
1595 assert_eq!(
1596 extract_theme(&bare).get("id").and_then(|v| v.as_u64()),
1597 Some(7)
1598 );
1599 }
1600
1601 #[test]
1602 fn value_display_renders_each_json_kind() {
1603 assert_eq!(value_display(&json!("right")), "right");
1604 assert_eq!(value_display(&Value::Null), "");
1605 assert_eq!(value_display(&json!(true)), "true");
1606 assert_eq!(value_display(&json!(42)), "42");
1607 assert_eq!(value_display(&json!(["a", "b"])), "[\"a\",\"b\"]");
1610 }
1611
1612 #[test]
1613 fn theme_setting_entries_parses_settings_array() {
1614 let theme = json!({
1615 "settings": [
1616 { "setting": "links_position", "type": "enum", "default": "right", "value": "left" },
1617 { "setting": "header_links", "type": "string", "default": "[]", "value": "[{\"id\":1}]" }
1618 ]
1619 });
1620 let entries = theme_setting_entries(&theme);
1621 assert_eq!(entries.len(), 2);
1622 assert_eq!(entries[0].setting, "links_position");
1623 assert_eq!(entries[0].kind, "enum");
1624 assert_eq!(value_display(&entries[0].value), "left");
1625 assert_eq!(entries[1].setting, "header_links");
1626 assert_eq!(value_display(&entries[1].value), "[{\"id\":1}]");
1627 }
1628
1629 #[test]
1630 fn theme_setting_entries_empty_when_absent() {
1631 assert!(theme_setting_entries(&json!({ "name": "no settings" })).is_empty());
1632 }
1633
1634 #[test]
1635 fn expand_json_list_expands_only_json_arrays_and_objects() {
1636 let v = expand_json_list(&json!("[{\"id\": 1, \"title\": \"A\"}]"));
1638 assert!(v.is_array());
1639 assert_eq!(v[0]["title"], json!("A"));
1640 assert!(expand_json_list(&json!("{\"a\": 1}")).is_object());
1642 assert_eq!(
1644 expand_json_list(&json!("var(--primary)")),
1645 json!("var(--primary)")
1646 );
1647 assert_eq!(expand_json_list(&json!("left")), json!("left"));
1648 assert_eq!(expand_json_list(&json!(true)), json!(true));
1650 assert_eq!(expand_json_list(&json!("[not json")), json!("[not json"));
1652 }
1653
1654 #[test]
1655 fn theme_value_to_send_serialises_lists_as_json_text() {
1656 assert_eq!(theme_value_to_send(&json!([{"id": 1}])), "[{\"id\":1}]");
1657 assert_eq!(theme_value_to_send(&json!("left")), "left");
1658 assert_eq!(theme_value_to_send(&json!(true)), "true");
1659 assert_eq!(theme_value_to_send(&Value::Null), "");
1660 }
1661
1662 #[test]
1663 fn json_equal_ignores_whitespace_for_lists() {
1664 assert!(json_equal("[{\"id\": 1}]", "[{\"id\":1}]"));
1666 assert!(json_equal("left", "left"));
1667 assert!(!json_equal("[{\"id\": 1}]", "[{\"id\":2}]"));
1668 assert!(!json_equal("split", "left"));
1669 }
1670
1671 #[test]
1672 fn header_links_round_trips_idempotently() {
1673 let server = json!("[{\"id\": 1, \"title\": \"Conference\", \"newTab\": true}]");
1675 let expanded = expand_json_list(&server);
1677 assert!(expanded.is_array());
1678 let current = theme_value_to_send(&server);
1681 assert!(
1682 json_equal(&theme_value_to_send(&expanded), ¤t),
1683 "an untouched list must be a no-op on push"
1684 );
1685 let mut edited = expanded.clone();
1687 edited[0]["title"] = json!("Conference 2027");
1688 assert!(!json_equal(&theme_value_to_send(&edited), ¤t));
1689 }
1690
1691 #[test]
1692 fn theme_settings_file_round_trips_through_yaml() {
1693 let file = ThemeSettingsFile {
1694 version: 1,
1695 discourse_version: Some("3.x".into()),
1696 theme_id: 17,
1697 theme_name: Some("Dropdown Header".into()),
1698 pulled_at: None,
1699 settings: vec![ThemeSettingsFileEntry {
1700 setting: "header_links".into(),
1701 kind: Some("string".into()),
1702 value: json!([{"id": 1, "title": "A"}]),
1703 default: None,
1704 }],
1705 };
1706 let yaml = serde_yaml::to_string(&file).unwrap();
1707 let back: ThemeSettingsFile = serde_yaml::from_str(&yaml).unwrap();
1708 assert_eq!(back.version, 1);
1709 assert_eq!(back.theme_id, 17);
1710 assert_eq!(back.settings.len(), 1);
1711 assert_eq!(back.settings[0].setting, "header_links");
1712 assert!(back.settings[0].value.is_array());
1713 assert_eq!(back.settings[0].value[0]["title"], json!("A"));
1714 }
1715
1716 #[test]
1717 fn describe_change_summarises_long_values() {
1718 assert_eq!(describe_change("split", "left"), "split -> left");
1719 let long = "x".repeat(200);
1720 assert!(describe_change(&long, &long).starts_with("changed ("));
1721 }
1722
1723 #[test]
1724 fn split_target_name_handles_slash_and_bare() {
1725 assert_eq!(
1726 split_target_name("common/scss"),
1727 ("common".into(), "scss".into())
1728 );
1729 assert_eq!(
1730 split_target_name("settings/yaml"),
1731 ("settings".into(), "yaml".into())
1732 );
1733 assert_eq!(split_target_name("scss"), (String::new(), "scss".into()));
1735 }
1736
1737 #[test]
1738 fn field_type_and_extension_map_ids() {
1739 assert_eq!(field_type_label(1), "scss");
1740 assert_eq!(field_type_label(0), "html");
1741 assert_eq!(field_type_label(2), "upload");
1742 assert_eq!(field_extension(1), "scss");
1743 assert_eq!(field_extension(0), "html");
1744 assert_eq!(field_extension(2), "txt");
1745 }
1746
1747 #[test]
1748 fn infer_type_id_from_name() {
1749 assert_eq!(infer_type_id("scss"), 1);
1750 assert_eq!(infer_type_id("embedded_scss"), 1);
1751 assert_eq!(infer_type_id("extra_js"), 4);
1752 assert_eq!(infer_type_id("head_tag"), 0);
1753 }
1754
1755 #[test]
1756 fn git_remote_theme_only_matches_git_backed() {
1757 assert!(git_remote_theme(&json!({ "remote_theme": null })).is_none());
1759 assert!(git_remote_theme(&json!({ "name": "local" })).is_none());
1760 let git = json!({ "remote_theme": { "is_git": true, "remote_url": "https://x/y.git" } });
1762 assert!(git_remote_theme(&git).is_some());
1763 let zip = json!({ "remote_theme": { "is_git": false } });
1765 assert!(git_remote_theme(&zip).is_none());
1766 }
1767
1768 #[test]
1769 fn theme_field_entries_parses_shape() {
1770 let theme = json!({
1771 "theme_fields": [
1772 { "target": "common", "name": "scss", "type_id": 1, "value": "body{}" },
1773 { "target": "common", "name": "logo", "type_id": 2, "value": "",
1774 "url": "/uploads/logo.png", "filename": "logo.png" }
1775 ]
1776 });
1777 let entries = theme_field_entries(&theme);
1778 assert_eq!(entries.len(), 2);
1779 assert_eq!(entries[0].field, "common/scss");
1780 assert_eq!(entries[0].kind, "scss");
1781 assert_eq!(entries[0].bytes, 6);
1782 assert!(entries[0].upload_url.is_none());
1783 assert_eq!(entries[1].kind, "upload");
1784 assert_eq!(entries[1].upload_url.as_deref(), Some("/uploads/logo.png"));
1785 }
1786
1787 #[test]
1788 fn find_theme_field_matches_target_and_name() {
1789 let theme = json!({
1790 "theme_fields": [
1791 { "target": "common", "name": "scss", "type_id": 1, "value": "a" },
1792 { "target": "desktop", "name": "scss", "type_id": 1, "value": "b" }
1793 ]
1794 });
1795 assert_eq!(
1796 find_theme_field(&theme, "desktop", "scss")
1797 .and_then(|f| f.get("value"))
1798 .and_then(|v| v.as_str()),
1799 Some("b")
1800 );
1801 assert!(find_theme_field(&theme, "mobile", "scss").is_none());
1802 }
1803
1804 #[test]
1805 fn short_hash_takes_eight() {
1806 assert_eq!(short_hash("0f474e72e256f4dfcd6685"), "0f474e72");
1807 assert_eq!(short_hash("abc"), "abc");
1808 }
1809
1810 #[test]
1811 fn looks_like_git_url_distinguishes_urls_from_paths() {
1812 assert!(looks_like_git_url("https://github.com/org/theme"));
1813 assert!(looks_like_git_url("http://x/y"));
1814 assert!(looks_like_git_url("git@github.com:org/theme.git"));
1815 assert!(looks_like_git_url("ssh://git@host/repo"));
1816 assert!(looks_like_git_url("/tmp/theme.git")); assert!(!looks_like_git_url("./my-theme.tar.gz"));
1818 assert!(!looks_like_git_url("/home/me/theme.zip"));
1819 }
1820
1821 #[test]
1822 fn redact_url_hides_credentials() {
1823 assert_eq!(
1824 redact_url("https://user:token@github.com/org/private.git"),
1825 "https://***@github.com/org/private.git"
1826 );
1827 assert_eq!(
1829 redact_url("https://github.com/org/public"),
1830 "https://github.com/org/public"
1831 );
1832 assert_eq!(redact_url("./local.tar.gz"), "./local.tar.gz");
1833 }
1834
1835 #[test]
1836 fn theme_relations_parses_id_name_pairs() {
1837 let theme = json!({
1838 "child_themes": [
1839 { "id": 8, "name": "Header Submenus" },
1840 { "id": 14, "name": "Dropdown Header" },
1841 { "name": "no id, skipped" }
1842 ]
1843 });
1844 let rels = theme_relations(&theme, "child_themes");
1845 assert_eq!(rels.len(), 2);
1846 assert_eq!(rels[0].id, 8);
1847 assert_eq!(rels[1].name, "Dropdown Header");
1848 assert!(theme_relations(&theme, "parent_themes").is_empty());
1849 }
1850
1851 #[test]
1852 fn theme_field_inventory_joins_target_and_name() {
1853 let theme = json!({
1854 "theme_fields": [
1855 { "target": "common", "name": "scss", "value": "body{}" },
1856 { "target": "desktop", "name": "scss", "value": "" },
1857 { "target": "", "name": "extra_js", "value": "" },
1858 { "value": "no name, skipped" }
1859 ]
1860 });
1861 let fields = theme_field_inventory(&theme);
1862 assert_eq!(fields, vec!["common/scss", "desktop/scss", "extra_js"]);
1863 }
1864
1865 #[test]
1866 fn build_theme_show_summarises_core_fields() {
1867 let theme = json!({
1868 "id": 11,
1869 "name": "kitchen-customisations",
1870 "component": false,
1871 "enabled": true,
1872 "default": false,
1873 "user_selectable": true,
1874 "child_themes": [{ "id": 14, "name": "Dropdown Header" }],
1875 "settings": [{ "setting": "links_position", "value": "left" }],
1876 "theme_fields": [{ "target": "common", "name": "scss", "value": "x" }]
1877 });
1878 let show = build_theme_show(&theme, 11);
1879 assert_eq!(show.id, 11);
1880 assert!(!show.component);
1881 assert!(show.enabled);
1882 assert_eq!(show.child_themes.len(), 1);
1883 assert_eq!(show.settings_count, 1);
1884 assert_eq!(show.fields, vec!["common/scss"]);
1885 }
1886}