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 crate::api::DiscourseClient;
use crate::cli::ListFormat;
use crate::commands::common::{ensure_api_credentials, select_discourse};
use crate::config::Config;
use anyhow::{Context, Result, anyhow};
use std::fs;
use std::io::{self, Read};
use std::path::Path;

pub fn pm_send(
    config: &Config,
    discourse_name: &str,
    recipients_csv: &str,
    title: &str,
    local_path: Option<&Path>,
    dry_run: bool,
) -> Result<()> {
    let discourse = select_discourse(config, Some(discourse_name))?;
    ensure_api_credentials(discourse)?;
    let client = DiscourseClient::new(discourse)?;

    if title.trim().is_empty() {
        return Err(anyhow!("PM title is empty"));
    }
    let recipients = parse_recipients(recipients_csv);
    if recipients.is_empty() {
        return Err(anyhow!("no recipients supplied"));
    }
    let raw = read_body(local_path)?;
    if raw.trim().is_empty() {
        return Err(anyhow!("PM body is empty"));
    }

    if dry_run {
        println!(
            "[dry-run] {}: would send PM titled \"{}\" to {} ({} bytes of body)",
            discourse.name,
            title,
            recipients.join(", "),
            raw.len()
        );
        return Ok(());
    }

    let topic_id = client.create_private_message(&recipients, title, &raw)?;
    println!(
        "Sent PM \"{}\" to {} (topic id {})",
        title,
        recipients.join(", "),
        topic_id
    );
    Ok(())
}

pub fn pm_list(
    config: &Config,
    discourse_name: &str,
    username: &str,
    direction: &str,
    format: ListFormat,
) -> Result<()> {
    let discourse = select_discourse(config, Some(discourse_name))?;
    ensure_api_credentials(discourse)?;
    let client = DiscourseClient::new(discourse)?;
    let topics = client.list_private_messages(username, direction)?;

    match format {
        ListFormat::Text => {
            if topics.is_empty() {
                println!("No PMs found in {}.", direction);
                return Ok(());
            }
            let id_width = topics
                .iter()
                .map(|t| t.id.to_string().len())
                .max()
                .unwrap_or(2);
            for t in &topics {
                let title = t.title.as_deref().unwrap_or("(untitled)");
                let last = t.last_posted_at.as_deref().unwrap_or("");
                let from = t.last_poster_username.as_deref().unwrap_or("");
                println!(
                    "{:>width$}  {}  [{} by {}]",
                    t.id,
                    title,
                    last,
                    from,
                    width = id_width
                );
            }
        }
        ListFormat::Json => println!("{}", serde_json::to_string_pretty(&topics)?),
        ListFormat::Yaml => println!("{}", serde_yaml::to_string(&topics)?),
    }
    Ok(())
}

fn parse_recipients(input: &str) -> Vec<String> {
    input
        .split(|ch| ch == ',' || ch == ';')
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .collect()
}

fn read_body(local_path: Option<&Path>) -> Result<String> {
    let from_stdin = match local_path {
        None => true,
        Some(p) => p.as_os_str() == "-",
    };
    if from_stdin {
        let mut buf = String::new();
        io::stdin()
            .read_to_string(&mut buf)
            .context("reading PM body from stdin")?;
        Ok(buf)
    } else {
        let path = local_path.unwrap();
        fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))
    }
}

#[cfg(test)]
mod tests {
    use super::parse_recipients;

    #[test]
    fn parses_csv() {
        let got = parse_recipients("alice,bob,charlie");
        assert_eq!(got, vec!["alice", "bob", "charlie"]);
    }

    #[test]
    fn trims_and_drops_blanks() {
        let got = parse_recipients("alice, bob , , charlie");
        assert_eq!(got, vec!["alice", "bob", "charlie"]);
    }

    #[test]
    fn accepts_semicolons() {
        let got = parse_recipients("alice;bob;charlie");
        assert_eq!(got, vec!["alice", "bob", "charlie"]);
    }

    #[test]
    fn handles_single_recipient() {
        let got = parse_recipients("alice");
        assert_eq!(got, vec!["alice"]);
    }

    #[test]
    fn empty_input_yields_empty_list() {
        let got = parse_recipients("");
        assert!(got.is_empty());
    }
}