dsc-rs 0.6.0

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};
use serde::de::Deserializer;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};

fn deserialize_opt_string_empty_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
    D: Deserializer<'de>,
{
    let value = Option::<String>::deserialize(deserializer)?;
    Ok(value.and_then(|s| if s.is_empty() { None } else { Some(s) }))
}

fn deserialize_opt_u64_zero_as_none<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
    D: Deserializer<'de>,
{
    let value = Option::<u64>::deserialize(deserializer)?;
    Ok(value.and_then(|v| if v == 0 { None } else { Some(v) }))
}

/// Top-level configuration for dsc.
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
pub struct Config {
    #[serde(default)]
    pub discourse: Vec<DiscourseConfig>,
}

/// Configuration for a single Discourse install.
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
pub struct DiscourseConfig {
    pub name: String,
    pub baseurl: String,
    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
    pub fullname: Option<String>,
    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
    pub apikey: Option<String>,
    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
    pub api_username: Option<String>,
    #[serde(default)]
    pub tags: Option<Vec<String>>,
    #[serde(default, deserialize_with = "deserialize_opt_u64_zero_as_none")]
    pub changelog_topic_id: Option<u64>,
    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
    pub ssh_host: Option<String>,
}

/// Load configuration from a TOML file.
pub fn load_config(path: &Path) -> Result<Config> {
    if !path.exists() {
        return Ok(Config::default());
    }
    let raw = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
    let config: Config = toml::from_str(&raw).with_context(|| "parsing config")?;
    warn_on_discourse_names(&config);
    Ok(config)
}

/// Save configuration to a TOML file.
pub fn save_config(path: &Path, config: &Config) -> Result<()> {
    let raw = toml::to_string_pretty(config).with_context(|| "serializing config")?;
    write_config_file(path, raw.as_bytes())?;
    Ok(())
}

#[cfg(unix)]
fn write_config_file(path: &Path, raw: &[u8]) -> Result<()> {
    use std::io::Write;
    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};

    let mut file = fs::OpenOptions::new()
        .create(true)
        .truncate(true)
        .write(true)
        .mode(0o600)
        .open(path)
        .with_context(|| format!("writing {}", path.display()))?;
    file.write_all(raw)
        .with_context(|| format!("writing {}", path.display()))?;

    let metadata = fs::metadata(path).with_context(|| format!("reading {}", path.display()))?;
    let mode = metadata.permissions().mode() & 0o777;
    if mode & 0o077 != 0 {
        if let Err(err) = fs::set_permissions(path, fs::Permissions::from_mode(0o600)) {
            eprintln!(
                "Warning: unable to tighten permissions on {}: {}",
                path.display(),
                err
            );
        }
    }
    Ok(())
}

#[cfg(not(unix))]
fn write_config_file(path: &Path, raw: &[u8]) -> Result<()> {
    fs::write(path, raw).with_context(|| format!("writing {}", path.display()))?;
    Ok(())
}

/// Find a discourse by name.
pub fn find_discourse<'a>(config: &'a Config, name: &str) -> Option<&'a DiscourseConfig> {
    config.discourse.iter().find(|d| d.name == name)
}

/// Find a discourse by name (mutable).
pub fn find_discourse_mut<'a>(
    config: &'a mut Config,
    name: &str,
) -> Option<&'a mut DiscourseConfig> {
    config.discourse.iter_mut().find(|d| d.name == name)
}

fn warn_on_discourse_names(config: &Config) {
    for discourse in &config.discourse {
        if discourse.name.chars().any(|ch| ch.is_whitespace()) {
            eprintln!(
                "Warning: discourse name '{}' contains whitespace. Prefer a short, slugified name without spaces; use 'fullname' for display.",
                discourse.name
            );
        }
    }
}

/// Resolve the default config path when `--config` is not provided.
///
/// Search order:
/// 1. `./dsc.toml`
/// 2. `$XDG_CONFIG_HOME/dsc/dsc.toml` (or `~/.config/dsc/dsc.toml`)
/// 3. System config locations (`$XDG_CONFIG_DIRS` + common Unix paths)
///
/// If none exist, defaults to `./dsc.toml`.
pub fn resolve_default_config_path() -> PathBuf {
    let local = PathBuf::from("dsc.toml");
    let mut candidates = vec![local.clone()];

    if let Some(xdg_config_home) = std::env::var_os("XDG_CONFIG_HOME") {
        candidates.push(PathBuf::from(xdg_config_home).join("dsc").join("dsc.toml"));
    } else if let Some(home) = std::env::var_os("HOME") {
        candidates.push(
            PathBuf::from(home)
                .join(".config")
                .join("dsc")
                .join("dsc.toml"),
        );
    }

    #[cfg(unix)]
    {
        if let Some(xdg_config_dirs) = std::env::var_os("XDG_CONFIG_DIRS") {
            for dir in std::env::split_paths(&xdg_config_dirs) {
                candidates.push(dir.join("dsc").join("dsc.toml"));
            }
        } else {
            candidates.push(PathBuf::from("/etc/xdg/dsc/dsc.toml"));
        }
        candidates.push(PathBuf::from("/etc/dsc/dsc.toml"));
        candidates.push(PathBuf::from("/etc/dsc.toml"));
        candidates.push(PathBuf::from("/usr/local/etc/dsc.toml"));
    }

    first_existing_config_path(candidates).unwrap_or(local)
}

fn first_existing_config_path<I>(candidates: I) -> Option<PathBuf>
where
    I: IntoIterator<Item = PathBuf>,
{
    candidates.into_iter().find(|candidate| candidate.exists())
}

#[cfg(test)]
mod tests {
    use super::first_existing_config_path;
    use std::path::PathBuf;

    #[test]
    fn returns_first_existing_path_in_order() {
        let dir = tempfile::tempdir().expect("tempdir");
        let first = dir.path().join("first.toml");
        let second = dir.path().join("second.toml");
        std::fs::write(&second, "").expect("write");
        std::fs::write(&first, "").expect("write");

        let selected = first_existing_config_path(vec![first.clone(), second]).expect("selected");
        assert_eq!(selected, first);
    }

    #[test]
    fn returns_none_when_no_candidates_exist() {
        let dir = tempfile::tempdir().expect("tempdir");
        let missing = dir.path().join("missing.toml");
        let selected =
            first_existing_config_path(vec![missing, PathBuf::from("/definitely/missing")]);
        assert!(selected.is_none());
    }
}