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