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