dsc-rs 0.10.15

Discourse CLI tool for managing multiple Discourse forums: track installs, run upgrades over SSH, manage emojis, sync topics and categories as Markdown, and more.
Documentation
use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;

use crate::api::DiscourseClient;
use crate::cli::ListFormat;
use crate::commands::common::{ensure_api_credentials, select_discourse};
use crate::config::Config;
use crate::utils::normalize_baseurl;

#[derive(Debug, Serialize, Deserialize)]
struct PaletteFile {
    #[serde(skip_serializing_if = "Option::is_none")]
    id: Option<u64>,
    name: String,
    colors: BTreeMap<String, String>,
}

#[derive(Debug, Serialize)]
struct PaletteListEntry {
    id: u64,
    name: String,
}

pub fn palette_list(
    config: &Config,
    discourse_name: &str,
    format: ListFormat,
    verbose: bool,
) -> Result<()> {
    let discourse = select_discourse(config, Some(discourse_name))?;
    ensure_api_credentials(discourse)?;
    let client = DiscourseClient::new(discourse)?;
    let response = client.list_color_schemes()?;
    let schemes = response
        .get("color_schemes")
        .and_then(|v| v.as_array())
        .cloned()
        .unwrap_or_default();
    let entries: Vec<PaletteListEntry> = schemes
        .into_iter()
        .map(|scheme| {
            let id = scheme
                .get("id")
                .or_else(|| scheme.get("color_scheme_id"))
                .and_then(|v| v.as_u64())
                .unwrap_or_default();
            let name = scheme
                .get("name")
                .or_else(|| scheme.get("color_scheme_name"))
                .and_then(|v| v.as_str())
                .unwrap_or("unknown")
                .to_string();
            PaletteListEntry { id, name }
        })
        .collect();

    match format {
        ListFormat::Text => {
            if entries.is_empty() && !verbose {
                println!("No palettes found.");
                return Ok(());
            }
            for entry in entries {
                println!("{} - {}", entry.id, entry.name);
            }
        }
        ListFormat::Json => {
            let raw = serde_json::to_string_pretty(&entries)?;
            println!("{}", raw);
        }
        ListFormat::Yaml => {
            let raw = serde_yaml::to_string(&entries)?;
            println!("{}", raw);
        }
    }
    Ok(())
}

pub fn palette_pull(
    config: &Config,
    discourse_name: &str,
    palette_id: u64,
    local_path: Option<&Path>,
) -> Result<()> {
    let discourse = select_discourse(config, Some(discourse_name))?;
    ensure_api_credentials(discourse)?;
    let client = DiscourseClient::new(discourse)?;
    let response = client.fetch_color_scheme(palette_id)?;
    let palette = palette_from_response(&response, palette_id)?;

    let path = match local_path {
        Some(path) => path.to_path_buf(),
        None => {
            let filename = format!("palette-{}.json", palette_id);
            std::env::current_dir()?.join(filename)
        }
    };
    write_palette_file(&path, &palette)?;
    println!("{}", path.display());
    Ok(())
}

pub fn palette_push(
    config: &Config,
    discourse_name: &str,
    local_path: &Path,
    palette_id: Option<u64>,
) -> Result<()> {
    let discourse = select_discourse(config, Some(discourse_name))?;
    ensure_api_credentials(discourse)?;
    let client = DiscourseClient::new(discourse)?;
    let mut palette = read_palette_file(local_path)?;

    if palette.colors.is_empty() {
        return Err(anyhow!("palette file contains no colors"));
    }

    let target_id = palette_id.or(palette.id);
    if let Some(target_id) = target_id {
        client.update_color_scheme(target_id, Some(&palette.name), &palette.colors)?;
        let url = format!(
            "{}/admin/customize/colors/{}",
            normalize_baseurl(&discourse.baseurl),
            target_id
        );
        println!("{}", url);
    } else {
        if palette.name.trim().is_empty() {
            return Err(anyhow!("missing palette name for palette create"));
        }
        let new_id = client.create_color_scheme(&palette.name, &palette.colors)?;
        palette.id = Some(new_id);
        write_palette_file(local_path, &palette)?;
        let url = format!(
            "{}/admin/customize/colors/{}",
            normalize_baseurl(&discourse.baseurl),
            new_id
        );
        println!("{}", url);
    }

    Ok(())
}

fn palette_from_response(response: &Value, fallback_id: u64) -> Result<PaletteFile> {
    let scheme = response.get("color_scheme").unwrap_or(response);
    let id = scheme
        .get("id")
        .or_else(|| scheme.get("color_scheme_id"))
        .and_then(|v| v.as_u64())
        .or_else(|| response.get("id").and_then(|v| v.as_u64()))
        .unwrap_or(fallback_id);
    let name = scheme
        .get("name")
        .or_else(|| scheme.get("color_scheme_name"))
        .and_then(|v| v.as_str())
        .unwrap_or("palette")
        .to_string();
    let colors_value = scheme
        .get("colors")
        .or_else(|| response.get("colors"))
        .unwrap_or(&Value::Null);
    let colors = colors_from_value(colors_value);
    if colors.is_empty() {
        return Err(anyhow!("palette is missing color values"));
    }
    Ok(PaletteFile {
        id: Some(id),
        name,
        colors,
    })
}

fn colors_from_value(value: &Value) -> BTreeMap<String, String> {
    match value {
        Value::Object(map) => map
            .iter()
            .filter_map(|(key, value)| value.as_str().map(|val| (key.clone(), val.to_string())))
            .collect(),
        Value::Array(items) => {
            let mut out = BTreeMap::new();
            for item in items {
                if let Some(name) = item.get("name").and_then(|v| v.as_str()) {
                    if let Some(hex) = item
                        .get("hex")
                        .and_then(|v| v.as_str())
                        .or_else(|| item.get("value").and_then(|v| v.as_str()))
                    {
                        out.insert(name.to_string(), hex.to_string());
                    }
                }
            }
            out
        }
        _ => BTreeMap::new(),
    }
}

fn read_palette_file(path: &Path) -> Result<PaletteFile> {
    let raw = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
    if is_yaml(path) {
        let palette: PaletteFile = serde_yaml::from_str(&raw).context("parsing palette yaml")?;
        return Ok(palette);
    }
    let palette: PaletteFile = serde_json::from_str(&raw).context("parsing palette json")?;
    Ok(palette)
}

fn write_palette_file(path: &Path, palette: &PaletteFile) -> Result<()> {
    let content = if is_yaml(path) {
        serde_yaml::to_string(palette).context("serializing palette yaml")?
    } else {
        serde_json::to_string_pretty(palette).context("serializing palette json")?
    };
    if let Some(parent) = path.parent() {
        if !parent.as_os_str().is_empty() {
            fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
        }
    }
    fs::write(path, content).with_context(|| format!("writing {}", path.display()))?;
    Ok(())
}

fn is_yaml(path: &Path) -> bool {
    matches!(
        path.extension().and_then(|s| s.to_str()),
        Some("yml") | Some("yaml")
    )
}