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::cli::{Cli, CompletionShell};
use crate::utils::ensure_dir;
use anyhow::{Context, Result};
use clap::CommandFactory;
use clap_complete::{Shell, generate};
use std::fs;
use std::io;
use std::path::Path;

pub fn write_completions(shell: CompletionShell, dir: Option<&Path>) -> Result<()> {
    let mut cmd = Cli::command();
    let name = cmd.get_name().to_string();
    match dir {
        Some(dir) => {
            ensure_dir(dir)?;
            let filename = match shell {
                CompletionShell::Bash => "dsc.bash",
                CompletionShell::Zsh => "_dsc",
                CompletionShell::Fish => "dsc.fish",
            };
            let path = dir.join(filename);
            let generator: Shell = shell.into();
            if matches!(shell, CompletionShell::Zsh) {
                let mut buffer = Vec::new();
                generate(generator, &mut cmd, name, &mut buffer);
                let content = String::from_utf8(buffer).context("decoding zsh completions")?;
                let content = inject_zsh_sort_style(content);
                let content = inject_zsh_dynamic_discourse_completion(content);
                fs::write(&path, content).with_context(|| format!("writing {}", path.display()))?;
            } else {
                let mut file = fs::File::create(&path)
                    .with_context(|| format!("creating {}", path.display()))?;
                generate(generator, &mut cmd, name, &mut file);
            }
            println!("Completion script written to: {}", path.display());
        }
        None => {
            let generator: Shell = shell.into();
            if matches!(shell, CompletionShell::Zsh) {
                let mut buffer = Vec::new();
                generate(generator, &mut cmd, name, &mut buffer);
                let content = String::from_utf8(buffer).context("decoding zsh completions")?;
                let content = inject_zsh_sort_style(content);
                let content = inject_zsh_dynamic_discourse_completion(content);
                print!("{}", content);
            } else {
                let mut stdout = io::stdout();
                generate(generator, &mut cmd, name, &mut stdout);
            }
        }
    }
    Ok(())
}

fn inject_zsh_sort_style(mut content: String) -> String {
    let style = "zstyle ':completion:*:dsc:*' sort false";
    if content.contains(style) {
        return content;
    }
    let marker = "autoload -U is-at-least\n";
    if let Some(pos) = content.find(marker) {
        let insert_at = pos + marker.len();
        content.insert_str(insert_at, &format!("\n{}\n", style));
        return content;
    }
    format!("{}\n\n{}", style, content)
}

fn inject_zsh_dynamic_discourse_completion(mut content: String) -> String {
    if !content.contains("_dsc_discourse_names()") {
        let marker = "autoload -U is-at-least\n";
        let function = "\n_dsc_discourse_names() {\n\
    local config_path\n\
    local i\n\
    for i in {1..$#words}; do\n\
        if [[ ${words[$i]} == -c || ${words[$i]} == --config ]]; then\n\
            config_path=${words[$((i+1))]}\n\
        elif [[ ${words[$i]} == --config=* ]]; then\n\
            config_path=${words[$i]#--config=}\n\
        fi\n\
    done\n\
\n\
    local cmd=(dsc list --format plaintext)\n\
    if [[ -n ${config_path:-} ]]; then\n\
        cmd+=(-c \"$config_path\")\n\
    fi\n\
\n\
    local -a names\n\
    names=(${(f)\"$(command ${cmd[@]} 2>/dev/null | sed 's/ - .*//')\"})\n\
    _describe -t discourses 'discourses' names\n\
}\n";
        if let Some(pos) = content.find(marker) {
            let insert_at = pos + marker.len();
            content.insert_str(insert_at, function);
        } else {
            content = format!("{}{}", function.trim_start(), content);
        }
    }

    let content = replace_discourse_arg_completion(content);
    replace_update_name_completion(content)
}

/// Replace `:_default` with `:_dsc_discourse_names` for all `':discourse ...`
/// positional args across every subcommand.
fn replace_discourse_arg_completion(content: String) -> String {
    // Each generated argument looks like:
    //   ':discourse -- Discourse name:_default'
    // We find every `':discourse` and replace the `:_default'` that closes it.
    let mut result = String::with_capacity(content.len());
    let mut remaining = content.as_str();

    while let Some(pos) = remaining.find("':discourse") {
        // Copy everything up to and including the match start.
        result.push_str(&remaining[..pos]);
        let after = &remaining[pos..];

        if let Some(default_pos) = after.find(":_default'") {
            // Copy the argument text up to the `:_default'`, replacing it.
            result.push_str(&after[..default_pos]);
            result.push_str(":_dsc_discourse_names'");
            remaining = &after[default_pos + ":_default'".len()..];
        } else {
            // No `:_default'` found — copy the token as-is and move on.
            result.push_str(&after[.."':discourse".len()]);
            remaining = &after["':discourse".len()..];
        }
    }
    result.push_str(remaining);
    result
}

fn replace_update_name_completion(content: String) -> String {
    let update_marker = "(update)\n_arguments";

    if let Some(update_pos) = content.find(update_marker) {
        let after_start = update_pos + update_marker.len();
        let after_update = &content[after_start..];

        // The generated argument spec includes the description, e.g.:
        //   ':name -- Discourse name, or '\''all'\'' to update ...:_default'
        // so we can't match ':name:_default' directly — find ':name' then
        // the next ':_default'' within that argument.
        if let Some(name_pos) = after_update.find("':name") {
            let after_name = &after_update[name_pos..];
            if let Some(default_pos) = after_name.find(":_default'") {
                let abs_pos = after_start + name_pos + default_pos;
                let mut result = content[..abs_pos].to_string();
                result.push_str(":_dsc_discourse_names'");
                result.push_str(&content[abs_pos + ":_default'".len()..]);
                return result;
            }
        }
    }

    content
}