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::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(
81 config: &Config,
82 discourse_name: &str,
83 url: &str,
84 dry_run: bool,
85) -> Result<()> {
86 let discourse = select_discourse(config, Some(discourse_name))?;
87 let target = ssh_target(discourse);
88 let template = std::env::var("DSC_SSH_THEME_INSTALL_CMD")
89 .map_err(|_| {
90 anyhow!(
91 "missing DSC_SSH_THEME_INSTALL_CMD for theme install; set DSC_SSH_THEME_INSTALL_CMD to your install command"
92 )
93 })?;
94 let command = render_template(&template, &[("url", url), ("name", url)]);
95 if dry_run {
96 println!("[dry-run] would run on {}: {}", target, command);
97 return Ok(());
98 }
99 let output = run_ssh_command(&target, &command)?;
100 println!("Theme install completed: {}", url);
101 if !output.trim().is_empty() {
102 println!("{}", output.trim());
103 }
104 Ok(())
105}
106
107pub fn theme_remove(
108 config: &Config,
109 discourse_name: &str,
110 name: &str,
111 dry_run: bool,
112) -> Result<()> {
113 let discourse = select_discourse(config, Some(discourse_name))?;
114 let target = ssh_target(discourse);
115 let template = std::env::var("DSC_SSH_THEME_REMOVE_CMD")
116 .map_err(|_| {
117 anyhow!(
118 "missing DSC_SSH_THEME_REMOVE_CMD for theme remove; set DSC_SSH_THEME_REMOVE_CMD to your remove command"
119 )
120 })?;
121 let command = render_template(&template, &[("name", name), ("url", name)]);
122 if dry_run {
123 println!("[dry-run] would run on {}: {}", target, command);
124 return Ok(());
125 }
126 let output = run_ssh_command(&target, &command)?;
127 println!("Theme removal completed: {}", name);
128 if !output.trim().is_empty() {
129 println!("{}", output.trim());
130 }
131 Ok(())
132}
133
134pub fn theme_pull(
136 config: &Config,
137 discourse_name: &str,
138 theme_id: u64,
139 local_path: Option<&Path>,
140) -> Result<()> {
141 let discourse = select_discourse(config, Some(discourse_name))?;
142 ensure_api_credentials(discourse)?;
143 let client = DiscourseClient::new(discourse)?;
144 let response = client.fetch_theme(theme_id)?;
145
146 let theme = response.get("theme").unwrap_or(&response);
148
149 let path = match local_path {
150 Some(p) => p.to_path_buf(),
151 None => {
152 let name_slug = theme
153 .get("name")
154 .and_then(|v| v.as_str())
155 .map(slugify)
156 .unwrap_or_else(|| format!("theme-{}", theme_id));
157 let filename = format!("{}.json", name_slug);
158 std::env::current_dir()
159 .context("getting current directory")?
160 .join(filename)
161 }
162 };
163
164 let content = serde_json::to_string_pretty(theme).context("serializing theme to JSON")?;
165 if let Some(parent) = path.parent()
166 && !parent.as_os_str().is_empty()
167 {
168 std::fs::create_dir_all(parent)
169 .with_context(|| format!("creating {}", parent.display()))?;
170 }
171 std::fs::write(&path, content).with_context(|| format!("writing {}", path.display()))?;
172 println!("{}", path.display());
173 Ok(())
174}
175
176pub fn theme_push(
178 config: &Config,
179 discourse_name: &str,
180 json_path: &Path,
181 theme_id: Option<u64>,
182) -> Result<()> {
183 let discourse = select_discourse(config, Some(discourse_name))?;
184 ensure_api_credentials(discourse)?;
185 let client = DiscourseClient::new(discourse)?;
186
187 let raw = std::fs::read_to_string(json_path)
188 .with_context(|| format!("reading {}", json_path.display()))?;
189 let parsed: Value = serde_json::from_str(&raw)
190 .with_context(|| format!("parsing JSON from {}", json_path.display()))?;
191
192 let theme = if let Some(inner) = parsed.get("theme") {
194 inner.clone()
195 } else {
196 parsed
197 };
198
199 let push_data = build_push_payload(&theme);
200
201 let target_id = theme_id.or_else(|| theme.get("id").and_then(|v| v.as_u64()));
202
203 if let Some(id) = target_id {
204 client.update_theme(id, &push_data)?;
205 println!("{}", id);
206 } else {
207 if push_data
208 .get("name")
209 .and_then(|v| v.as_str())
210 .map(|s| s.trim().is_empty())
211 .unwrap_or(true)
212 {
213 return Err(anyhow!(
214 "missing name in theme file; set name or pass a theme ID to update"
215 ));
216 }
217 let new_id = client.create_theme(&push_data)?;
218 println!("{}", new_id);
219 }
220
221 Ok(())
222}
223
224pub fn theme_duplicate(
226 config: &Config,
227 discourse_name: &str,
228 theme_id: u64,
229 format: ListFormat,
230) -> Result<()> {
231 let discourse = select_discourse(config, Some(discourse_name))?;
232 ensure_api_credentials(discourse)?;
233 let client = DiscourseClient::new(discourse)?;
234
235 let response = client.fetch_theme(theme_id)?;
236 let theme = response.get("theme").unwrap_or(&response);
237
238 let original_name = theme
239 .get("name")
240 .and_then(|v| v.as_str())
241 .unwrap_or("Unknown");
242 let new_name = format!("Copy of {}", original_name);
243
244 let mut push_data = build_push_payload(theme);
245 push_data["name"] = Value::String(new_name);
246 push_data["default"] = Value::Bool(false);
248
249 let new_id = client.create_theme(&push_data)?;
250 emit_result(format, &json!({ "id": new_id }), &new_id.to_string())
251}
252
253fn build_push_payload(theme: &Value) -> Value {
256 let mut map = serde_json::Map::new();
257 for key in &[
258 "name",
259 "enabled",
260 "user_selectable",
261 "color_scheme_id",
262 "theme_fields",
263 "component",
264 ] {
265 if let Some(val) = theme.get(key) {
266 map.insert(key.to_string(), val.clone());
267 }
268 }
269 Value::Object(map)
270}
271
272fn ssh_target(discourse: &DiscourseConfig) -> String {
273 discourse
274 .ssh_host
275 .clone()
276 .unwrap_or_else(|| discourse.name.clone())
277}
278
279fn render_template(template: &str, replacements: &[(&str, &str)]) -> String {
280 let mut out = template.to_string();
281 for (key, value) in replacements {
282 out = out.replace(&format!("{{{}}}", key), value);
283 }
284 out
285}
286
287#[derive(Debug, Serialize)]
294struct ThemeSettingEntry {
295 setting: String,
296 #[serde(rename = "type")]
297 kind: String,
298 value: Value,
299 default: Value,
300}
301
302fn extract_theme(value: &Value) -> &Value {
305 value.get("theme").unwrap_or(value)
306}
307
308fn value_display(v: &Value) -> String {
311 match v {
312 Value::String(s) => s.clone(),
313 Value::Null => String::new(),
314 other => other.to_string(),
315 }
316}
317
318fn theme_setting_entries(theme: &Value) -> Vec<ThemeSettingEntry> {
319 theme
320 .get("settings")
321 .and_then(|v| v.as_array())
322 .map(|arr| {
323 arr.iter()
324 .map(|s| ThemeSettingEntry {
325 setting: s
326 .get("setting")
327 .and_then(|v| v.as_str())
328 .unwrap_or("")
329 .to_string(),
330 kind: s
331 .get("type")
332 .and_then(|v| v.as_str())
333 .unwrap_or("")
334 .to_string(),
335 value: s.get("value").cloned().unwrap_or(Value::Null),
336 default: s.get("default").cloned().unwrap_or(Value::Null),
337 })
338 .collect()
339 })
340 .unwrap_or_default()
341}
342
343pub fn theme_setting_list(
345 config: &Config,
346 discourse_name: &str,
347 theme_id: u64,
348 format: ListFormat,
349) -> Result<()> {
350 let discourse = select_discourse(config, Some(discourse_name))?;
351 ensure_api_credentials(discourse)?;
352 let client = DiscourseClient::new(discourse)?;
353 let response = client.fetch_theme(theme_id)?;
354 let theme = extract_theme(&response);
355 let entries = theme_setting_entries(theme);
356 match format {
357 ListFormat::Text => {
358 if entries.is_empty() {
359 println!("No settings found for theme {}.", theme_id);
360 return Ok(());
361 }
362 for entry in &entries {
363 println!("{} = {}", entry.setting, value_display(&entry.value));
364 }
365 }
366 ListFormat::Json => println!("{}", serde_json::to_string_pretty(&entries)?),
367 ListFormat::Yaml => println!("{}", serde_yaml::to_string(&entries)?),
368 }
369 Ok(())
370}
371
372pub fn theme_setting_get(
374 config: &Config,
375 discourse_name: &str,
376 theme_id: u64,
377 key: &str,
378 format: ListFormat,
379) -> Result<()> {
380 let discourse = select_discourse(config, Some(discourse_name))?;
381 ensure_api_credentials(discourse)?;
382 let client = DiscourseClient::new(discourse)?;
383 let response = client.fetch_theme(theme_id)?;
384 let theme = extract_theme(&response);
385 let setting = theme
386 .get("settings")
387 .and_then(|v| v.as_array())
388 .and_then(|arr| {
389 arr.iter()
390 .find(|s| s.get("setting").and_then(|v| v.as_str()) == Some(key))
391 })
392 .ok_or_else(|| not_found("theme setting", key))?;
393 let value = setting.get("value").cloned().unwrap_or(Value::Null);
394 emit_result(
395 format,
396 &json!({ "setting": key, "value": value }),
397 &value_display(&value),
398 )
399}
400
401pub fn theme_setting_set(
404 config: &Config,
405 discourse_name: &str,
406 theme_id: u64,
407 key: &str,
408 value: &str,
409 dry_run: bool,
410) -> Result<()> {
411 let discourse = select_discourse(config, Some(discourse_name))?;
412 ensure_api_credentials(discourse)?;
413 let client = DiscourseClient::new(discourse)?;
414 if dry_run {
415 println!(
416 "[dry-run] {}: would set theme {} setting {} = {}",
417 discourse.name, theme_id, key, value
418 );
419 return Ok(());
420 }
421 client.set_theme_setting(theme_id, key, value)?;
422 println!("{}: set theme {} setting {}", discourse.name, theme_id, key);
423 Ok(())
424}
425
426pub fn theme_set_enabled(
429 config: &Config,
430 discourse_name: &str,
431 theme_id: u64,
432 enabled: bool,
433 dry_run: bool,
434) -> Result<()> {
435 let discourse = select_discourse(config, Some(discourse_name))?;
436 ensure_api_credentials(discourse)?;
437 let client = DiscourseClient::new(discourse)?;
438 let action = if enabled { "enable" } else { "disable" };
439 if dry_run {
440 println!(
441 "[dry-run] {}: would {} theme {}",
442 discourse.name, action, theme_id
443 );
444 return Ok(());
445 }
446 client.update_theme(theme_id, &json!({ "enabled": enabled }))?;
447 println!("{}: {}d theme {}", discourse.name, action, theme_id);
448 Ok(())
449}
450
451pub fn theme_set_child(
455 config: &Config,
456 discourse_name: &str,
457 parent_id: u64,
458 component_id: u64,
459 attach: bool,
460 dry_run: bool,
461) -> Result<()> {
462 let discourse = select_discourse(config, Some(discourse_name))?;
463 ensure_api_credentials(discourse)?;
464 let client = DiscourseClient::new(discourse)?;
465 let response = client.fetch_theme(parent_id)?;
466 let theme = extract_theme(&response);
467 let mut child_ids: Vec<u64> = theme
468 .get("child_themes")
469 .and_then(|v| v.as_array())
470 .map(|arr| {
471 arr.iter()
472 .filter_map(|c| c.get("id").and_then(|v| v.as_u64()))
473 .collect()
474 })
475 .unwrap_or_default();
476
477 let present = child_ids.contains(&component_id);
478 if attach && present {
479 println!(
480 "{}: component {} already attached to theme {}",
481 discourse.name, component_id, parent_id
482 );
483 return Ok(());
484 }
485 if !attach && !present {
486 println!(
487 "{}: component {} is not attached to theme {}",
488 discourse.name, component_id, parent_id
489 );
490 return Ok(());
491 }
492 if attach {
493 child_ids.push(component_id);
494 } else {
495 child_ids.retain(|&id| id != component_id);
496 }
497
498 let (verb, prep) = if attach {
499 ("attach", "to")
500 } else {
501 ("detach", "from")
502 };
503 if dry_run {
504 println!(
505 "[dry-run] {}: would {} component {} {} theme {} (child_theme_ids -> {:?})",
506 discourse.name, verb, component_id, prep, parent_id, child_ids
507 );
508 return Ok(());
509 }
510 client.update_theme(parent_id, &json!({ "child_theme_ids": child_ids }))?;
511 println!(
512 "{}: {}ed component {} {} theme {}",
513 discourse.name, verb, component_id, prep, parent_id
514 );
515 Ok(())
516}
517
518#[derive(Debug, Serialize)]
519struct ThemeRelation {
520 id: u64,
521 name: String,
522}
523
524#[derive(Debug, Serialize)]
525struct ThemeShow {
526 id: u64,
527 name: String,
528 component: bool,
529 enabled: bool,
530 default: bool,
531 user_selectable: bool,
532 color_scheme_id: Option<u64>,
533 parent_themes: Vec<ThemeRelation>,
534 child_themes: Vec<ThemeRelation>,
535 settings_count: usize,
536 fields: Vec<String>,
537}
538
539fn theme_relations(theme: &Value, key: &str) -> Vec<ThemeRelation> {
542 theme
543 .get(key)
544 .and_then(|v| v.as_array())
545 .map(|arr| {
546 arr.iter()
547 .filter_map(|r| {
548 let id = r.get("id").and_then(|v| v.as_u64())?;
549 let name = r
550 .get("name")
551 .and_then(|v| v.as_str())
552 .unwrap_or("unknown")
553 .to_string();
554 Some(ThemeRelation { id, name })
555 })
556 .collect()
557 })
558 .unwrap_or_default()
559}
560
561fn theme_field_inventory(theme: &Value) -> Vec<String> {
565 theme
566 .get("theme_fields")
567 .and_then(|v| v.as_array())
568 .map(|arr| {
569 arr.iter()
570 .filter_map(|f| {
571 let name = f.get("name").and_then(|v| v.as_str())?;
572 let target = f.get("target").and_then(|v| v.as_str()).unwrap_or("");
573 if target.is_empty() {
574 Some(name.to_string())
575 } else {
576 Some(format!("{}/{}", target, name))
577 }
578 })
579 .collect()
580 })
581 .unwrap_or_default()
582}
583
584fn build_theme_show(theme: &Value, theme_id: u64) -> ThemeShow {
585 ThemeShow {
586 id: theme.get("id").and_then(|v| v.as_u64()).unwrap_or(theme_id),
587 name: theme
588 .get("name")
589 .and_then(|v| v.as_str())
590 .unwrap_or("unknown")
591 .to_string(),
592 component: theme
593 .get("component")
594 .and_then(|v| v.as_bool())
595 .unwrap_or(false),
596 enabled: theme
597 .get("enabled")
598 .and_then(|v| v.as_bool())
599 .unwrap_or(false),
600 default: theme
601 .get("default")
602 .and_then(|v| v.as_bool())
603 .unwrap_or(false),
604 user_selectable: theme
605 .get("user_selectable")
606 .and_then(|v| v.as_bool())
607 .unwrap_or(false),
608 color_scheme_id: theme.get("color_scheme_id").and_then(|v| v.as_u64()),
609 parent_themes: theme_relations(theme, "parent_themes"),
610 child_themes: theme_relations(theme, "child_themes"),
611 settings_count: theme_setting_entries(theme).len(),
612 fields: theme_field_inventory(theme),
613 }
614}
615
616fn format_relations(rels: &[ThemeRelation]) -> String {
617 if rels.is_empty() {
618 "(none)".to_string()
619 } else {
620 rels.iter()
621 .map(|r| format!("{} - {}", r.id, r.name))
622 .collect::<Vec<_>>()
623 .join(", ")
624 }
625}
626
627pub fn theme_show(
631 config: &Config,
632 discourse_name: &str,
633 theme_id: u64,
634 format: ListFormat,
635) -> Result<()> {
636 let discourse = select_discourse(config, Some(discourse_name))?;
637 ensure_api_credentials(discourse)?;
638 let client = DiscourseClient::new(discourse)?;
639 let response = client.fetch_theme(theme_id)?;
640 let theme = extract_theme(&response);
641 let show = build_theme_show(theme, theme_id);
642 match format {
643 ListFormat::Json => println!("{}", serde_json::to_string_pretty(&show)?),
644 ListFormat::Yaml => println!("{}", serde_yaml::to_string(&show)?),
645 ListFormat::Text => {
646 println!("{} - {}", show.id, show.name);
647 println!(
648 " type: {}",
649 if show.component { "component" } else { "theme" }
650 );
651 println!(" enabled: {}", show.enabled);
652 println!(" default: {}", show.default);
653 println!(" user-selectable: {}", show.user_selectable);
654 if let Some(cs) = show.color_scheme_id {
655 println!(" color scheme: {}", cs);
656 }
657 println!(
658 " parents: {}",
659 format_relations(&show.parent_themes)
660 );
661 println!(
662 " children: {}",
663 format_relations(&show.child_themes)
664 );
665 println!(" settings: {}", show.settings_count);
666 let fields = if show.fields.is_empty() {
667 "(none)".to_string()
668 } else {
669 show.fields.join(", ")
670 };
671 println!(" fields: {}", fields);
672 }
673 }
674 Ok(())
675}
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680
681 #[test]
682 fn extract_theme_unwraps_envelope_and_passes_bare() {
683 let wrapped = json!({ "theme": { "id": 11, "name": "kitchen" } });
684 assert_eq!(
685 extract_theme(&wrapped).get("id").and_then(|v| v.as_u64()),
686 Some(11)
687 );
688 let bare = json!({ "id": 7, "name": "bare" });
689 assert_eq!(
690 extract_theme(&bare).get("id").and_then(|v| v.as_u64()),
691 Some(7)
692 );
693 }
694
695 #[test]
696 fn value_display_renders_each_json_kind() {
697 assert_eq!(value_display(&json!("right")), "right");
698 assert_eq!(value_display(&Value::Null), "");
699 assert_eq!(value_display(&json!(true)), "true");
700 assert_eq!(value_display(&json!(42)), "42");
701 assert_eq!(value_display(&json!(["a", "b"])), "[\"a\",\"b\"]");
704 }
705
706 #[test]
707 fn theme_setting_entries_parses_settings_array() {
708 let theme = json!({
709 "settings": [
710 { "setting": "links_position", "type": "enum", "default": "right", "value": "left" },
711 { "setting": "header_links", "type": "string", "default": "[]", "value": "[{\"id\":1}]" }
712 ]
713 });
714 let entries = theme_setting_entries(&theme);
715 assert_eq!(entries.len(), 2);
716 assert_eq!(entries[0].setting, "links_position");
717 assert_eq!(entries[0].kind, "enum");
718 assert_eq!(value_display(&entries[0].value), "left");
719 assert_eq!(entries[1].setting, "header_links");
720 assert_eq!(value_display(&entries[1].value), "[{\"id\":1}]");
721 }
722
723 #[test]
724 fn theme_setting_entries_empty_when_absent() {
725 assert!(theme_setting_entries(&json!({ "name": "no settings" })).is_empty());
726 }
727
728 #[test]
729 fn theme_relations_parses_id_name_pairs() {
730 let theme = json!({
731 "child_themes": [
732 { "id": 8, "name": "Header Submenus" },
733 { "id": 14, "name": "Dropdown Header" },
734 { "name": "no id, skipped" }
735 ]
736 });
737 let rels = theme_relations(&theme, "child_themes");
738 assert_eq!(rels.len(), 2);
739 assert_eq!(rels[0].id, 8);
740 assert_eq!(rels[1].name, "Dropdown Header");
741 assert!(theme_relations(&theme, "parent_themes").is_empty());
742 }
743
744 #[test]
745 fn theme_field_inventory_joins_target_and_name() {
746 let theme = json!({
747 "theme_fields": [
748 { "target": "common", "name": "scss", "value": "body{}" },
749 { "target": "desktop", "name": "scss", "value": "" },
750 { "target": "", "name": "extra_js", "value": "" },
751 { "value": "no name, skipped" }
752 ]
753 });
754 let fields = theme_field_inventory(&theme);
755 assert_eq!(fields, vec!["common/scss", "desktop/scss", "extra_js"]);
756 }
757
758 #[test]
759 fn build_theme_show_summarises_core_fields() {
760 let theme = json!({
761 "id": 11,
762 "name": "kitchen-customisations",
763 "component": false,
764 "enabled": true,
765 "default": false,
766 "user_selectable": true,
767 "child_themes": [{ "id": 14, "name": "Dropdown Header" }],
768 "settings": [{ "setting": "links_position", "value": "left" }],
769 "theme_fields": [{ "target": "common", "name": "scss", "value": "x" }]
770 });
771 let show = build_theme_show(&theme, 11);
772 assert_eq!(show.id, 11);
773 assert!(!show.component);
774 assert!(show.enabled);
775 assert_eq!(show.child_themes.len(), 1);
776 assert_eq!(show.settings_count, 1);
777 assert_eq!(show.fields, vec!["common/scss"]);
778 }
779}