Skip to main content

dsc/commands/
theme.rs

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
134/// Pull a theme to a local JSON file.
135pub 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    // Unwrap {"theme": {...}} envelope if present
147    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
176/// Push a local JSON file to create or update a theme.
177pub 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    // Unwrap {"theme": {...}} envelope if present
193    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
224/// Duplicate a theme and print the new theme ID.
225pub 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    // Never copy the default status to the duplicate
247    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
253/// Build a payload suitable for creating or updating a theme.
254/// Strips server-generated and read-only fields.
255fn 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// ---------------------------------------------------------------------------
288// Phase 1: component settings + enable/disable + attach/detach
289// (spec/theme-management.md). Themes are handled as raw JSON values, matching
290// the rest of this module.
291// ---------------------------------------------------------------------------
292
293#[derive(Debug, Serialize)]
294struct ThemeSettingEntry {
295    setting: String,
296    #[serde(rename = "type")]
297    kind: String,
298    value: Value,
299    default: Value,
300}
301
302/// Unwrap the `{ "theme": { … } }` envelope returned by some endpoints,
303/// falling back to the bare object.
304fn extract_theme(value: &Value) -> &Value {
305    value.get("theme").unwrap_or(value)
306}
307
308/// Render a setting value for human-readable (text) output: strings bare,
309/// null as empty, everything else as compact JSON.
310fn 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
343/// List a theme/component's settings (distinct from site settings).
344pub 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
372/// Print a single theme/component setting's current value.
373pub 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
401/// Set a single theme/component setting. The value is sent verbatim, so a
402/// JSON-schema list setting takes its JSON text directly.
403pub 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
426/// Enable or disable a theme/component (`PUT /admin/themes/:id.json` toggling
427/// the `enabled` boolean).
428pub 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
451/// Attach or detach a component to/from a parent theme. Reads the parent's
452/// current `child_themes`, adds/removes the component id, and PUTs the full
453/// replacement `child_theme_ids` set (disabled components stay in the list).
454pub 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
539/// Parse an array of `{id, name}` theme relations (child/parent themes),
540/// skipping entries missing an id.
541fn 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
561/// Inventory of editable `theme_fields` as `target/name` strings (e.g.
562/// `common/scss`). Parsed defensively so an unexpected entry shape just
563/// contributes nothing rather than erroring.
564fn 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
627/// Show a richer view of one theme/component than `theme list`: type, enabled
628/// and default flags, parents, attached children, settings count, and the
629/// editable field inventory.
630pub 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        // json-schema list settings arrive as a JSON string already; an actual
702        // array still renders as compact JSON for text output.
703        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}