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::{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
80/// Install a theme/component via the admin import API, from either a git repo
81/// (a URL, optionally with embedded credentials for a private repo) or a local
82/// bundle file (a `.tar.gz`/zip theme export).
83pub 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
138/// Heuristic: does this install source look like a git URL (vs a local path)?
139fn 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
147/// Redact `user:token@` credentials from a URL before printing it.
148fn 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
158/// Delete a theme/component by id via the admin API. Refuses the site default.
159pub 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
221/// Pull a theme to a local JSON file.
222pub 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    // Unwrap {"theme": {...}} envelope if present
234    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
263/// Push a local JSON file to create or update a theme.
264pub 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    // Unwrap {"theme": {...}} envelope if present
280    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
311/// Duplicate a theme and print the new theme ID.
312pub 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    // Never copy the default status to the duplicate
334    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
340/// Build a payload suitable for creating or updating a theme.
341/// Strips server-generated and read-only fields.
342fn 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// ---------------------------------------------------------------------------
375// Phase 1: component settings + enable/disable + attach/detach
376// (spec/theme-management.md). Themes are handled as raw JSON values, matching
377// the rest of this module.
378// ---------------------------------------------------------------------------
379
380#[derive(Debug, Serialize)]
381struct ThemeSettingEntry {
382    setting: String,
383    #[serde(rename = "type")]
384    kind: String,
385    value: Value,
386    default: Value,
387}
388
389/// Unwrap the `{ "theme": { … } }` envelope returned by some endpoints,
390/// falling back to the bare object.
391fn extract_theme(value: &Value) -> &Value {
392    value.get("theme").unwrap_or(value)
393}
394
395/// Render a setting value for human-readable (text) output: strings bare,
396/// null as empty, everything else as compact JSON.
397fn 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// ─── theme setting pull/push file format ───────────────────────────────────
431
432/// On-disk snapshot of a theme/component's settings. `version` gates the
433/// schema; the rest is a header plus the editable settings list.
434#[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/// One setting in the snapshot. `type`/`default` are informational context for
448/// the human editor and are ignored on push; only `setting` + `value` matter.
449#[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
459/// JSON-schema list settings (e.g. `header_links`) arrive as a string whose
460/// content is a JSON array/object. Expand that to the real structure so it is
461/// editable as a list, not one escaped line. Anything else passes through
462/// unchanged (plain strings like `var(--primary)` are left alone).
463fn 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
474/// Serialise a snapshot value to the string Discourse expects on
475/// `PUT /admin/themes/:id/setting.json`. Arrays/objects (JSON-schema list
476/// settings) become compact JSON text; scalars become their plain form. This
477/// is deliberately NOT the site-settings `value_to_send_string`, which
478/// pipe-joins arrays - theme list settings are JSON, not pipe-delimited.
479fn 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
487/// Compare two wire-strings for equality. A JSON-list setting round-trips as
488/// compact JSON from the file but the server stores it spaced, so compare the
489/// parsed JSON when both sides parse; otherwise compare literally.
490fn 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
500/// Render a change for the `--dry-run` plan: short values inline, long ones
501/// (the big link lists) summarised by length so the terminal isn't flooded.
502/// Both sides are normalised first so a list's size delta reflects the real
503/// edit, not the compact-vs-spaced JSON serialisation difference between the
504/// file and the server.
505fn 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
516/// Re-serialise JSON arrays/objects to a canonical compact form so two sides
517/// of a diff are measured alike; leave everything else untouched.
518fn 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
532/// List a theme/component's settings (distinct from site settings).
533pub 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
561/// Print a single theme/component setting's current value.
562pub 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
590/// Set a single theme/component setting. The value is sent verbatim, so a
591/// JSON-schema list setting takes its JSON text directly.
592pub 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
615/// Pull a theme/component's settings to a local file for offline editing.
616///
617/// JSON-schema list settings (e.g. `header_links`, `dropdown_links`) arrive
618/// from Discourse as a single string of escaped JSON; this expands them to
619/// real arrays so they can be edited by hand rather than as one escaped line.
620/// YAML by default; a `.json` destination writes JSON.
621pub 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
699/// Push a settings file back to a theme/component, PUTting only the settings
700/// whose value differs from the server (idempotent). Re-serialises expanded
701/// JSON-list settings back to the escaped-string form Discourse expects.
702/// Honours `--dry-run`.
703pub 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    // Current server values, to PUT only what actually changed.
729    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, &current) {
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// ─── theme field (raw theme_fields: SCSS, head_tag, ...) ───────────────────
795
796#[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
806/// Discourse `ThemeField` type ids (see `ThemeField.types`).
807fn 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
818/// Sensible file extension for a pulled field body.
819fn 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
829/// Best-effort `type_id` for a *new* field from its name (only used when the
830/// field doesn't already exist server-side; the normal edit path reuses the
831/// existing field's type).
832fn 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
844/// Split a `target/name` field spec. A spec with no `/` is treated as a bare
845/// name with an empty target (some fields have no target).
846fn 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
864/// The `remote_theme` object, but only when it's a git-backed remote (the case
865/// where the DB is not the source of truth and where `theme update` applies).
866fn 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
910/// List a theme's editable fields (`target/name`, type, size).
911pub 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
942/// Pull one field's body (e.g. `common/scss`) to a local file.
943pub 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
996/// Push a local file back to one field. Refuses git-backed remote themes, where
997/// the repo (not the DB) owns the field.
998pub 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    // A single-entry `theme_fields` array upserts just this field, leaving the
1053    // theme's other fields untouched.
1054    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// ─── theme asset (upload + bind a theme_upload_var) ────────────────────────
1069
1070#[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
1079/// List a theme's bound upload assets (the `$var` uploads).
1080pub 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
1132/// Upload a file and bind it to a theme upload var (`$name`) in one step.
1133pub 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    // Upload vars live on the `common` target.
1156    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
1177/// Remove a theme upload var binding. Clearing the field's value and upload
1178/// deletes the `theme_upload_var` field server-side.
1179pub 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
1220// ─── theme update (remote/git-backed component refresh) ────────────────────
1221
1222/// Pull a git-backed remote component to its latest upstream commit. With
1223/// `check`, only report how far behind it is without pulling.
1224pub 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        // Refresh the upstream comparison without pulling.
1256        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
1304/// Enable or disable a theme/component (`PUT /admin/themes/:id.json` toggling
1305/// the `enabled` boolean).
1306pub 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
1329/// Attach or detach a component to/from a parent theme. Reads the parent's
1330/// current `child_themes`, adds/removes the component id, and PUTs the full
1331/// replacement `child_theme_ids` set (disabled components stay in the list).
1332pub 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
1417/// Parse an array of `{id, name}` theme relations (child/parent themes),
1418/// skipping entries missing an id.
1419fn 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
1439/// Inventory of editable `theme_fields` as `target/name` strings (e.g.
1440/// `common/scss`). Parsed defensively so an unexpected entry shape just
1441/// contributes nothing rather than erroring.
1442fn 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
1505/// Show a richer view of one theme/component than `theme list`: type, enabled
1506/// and default flags, parents, attached children, settings count, and the
1507/// editable field inventory.
1508pub 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        // json-schema list settings arrive as a JSON string already; an actual
1580        // array still renders as compact JSON for text output.
1581        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        // The header_links shape: a string holding a JSON array -> real array.
1609        let v = expand_json_list(&json!("[{\"id\": 1, \"title\": \"A\"}]"));
1610        assert!(v.is_array());
1611        assert_eq!(v[0]["title"], json!("A"));
1612        // A JSON object string -> object.
1613        assert!(expand_json_list(&json!("{\"a\": 1}")).is_object());
1614        // Plain strings (CSS vars, enums) are left alone.
1615        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        // Non-strings pass through.
1621        assert_eq!(expand_json_list(&json!(true)), json!(true));
1622        // Starts with '[' but isn't valid JSON -> stays a string.
1623        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        // Server stores spaced JSON; the file round-trips to compact JSON.
1637        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        // A realistic server value: a spaced JSON string, as Discourse returns it.
1646        let server = json!("[{\"id\": 1, \"title\": \"Conference\", \"newTab\": true}]");
1647        // pull: expand to an editable array.
1648        let expanded = expand_json_list(&server);
1649        assert!(expanded.is_array());
1650        // push (unedited): the array serialises back and compares equal to the
1651        // server's spaced form, so an untouched list is never needlessly PUT.
1652        let current = theme_value_to_send(&server);
1653        assert!(
1654            json_equal(&theme_value_to_send(&expanded), &current),
1655            "an untouched list must be a no-op on push"
1656        );
1657        // Edit one title -> it now differs and would be pushed.
1658        let mut edited = expanded.clone();
1659        edited[0]["title"] = json!("Conference 2027");
1660        assert!(!json_equal(&theme_value_to_send(&edited), &current));
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        // No slash -> empty target, whole thing is the name.
1706        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        // Locally authored: remote_theme is null.
1730        assert!(git_remote_theme(&json!({ "remote_theme": null })).is_none());
1731        assert!(git_remote_theme(&json!({ "name": "local" })).is_none());
1732        // Git-backed remote component.
1733        let git = json!({ "remote_theme": { "is_git": true, "remote_url": "https://x/y.git" } });
1734        assert!(git_remote_theme(&git).is_some());
1735        // A non-git remote (e.g. zip import) is not updatable.
1736        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")); // .git suffix
1789        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        // No credentials -> unchanged.
1800        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}