deslicer-cli 1.0.0

Deslicer CLI — vendor-neutral CI client for planning, approving, and shipping Splunk changes via DAP.
Documentation
use crate::ci::{detect_platform, CiPlatform};
use crate::observer_client::{ChangePlan, PlanProgress};
use std::collections::BTreeMap;
use std::fs::OpenOptions;
use std::io::{self, Write};
use std::path::PathBuf;

#[derive(Debug, Clone, Copy)]
pub enum OutputSink {
    GithubOutput,
    GitlabDotenv,
    AzureLogging,
    BitbucketStorage,
    Stdout,
}

pub fn detect_sink(platform: CiPlatform) -> OutputSink {
    match platform {
        CiPlatform::Github => OutputSink::GithubOutput,
        CiPlatform::Gitlab => OutputSink::GitlabDotenv,
        CiPlatform::Azure => OutputSink::AzureLogging,
        CiPlatform::Bitbucket => OutputSink::BitbucketStorage,
        CiPlatform::Local => OutputSink::Stdout,
    }
}

fn format_azure_line(key: &str, value: &str) -> String {
    format!("##vso[task.setvariable variable={key}]{value}")
}

fn append_kv_file(path: &str, pairs: &[(&str, String)]) -> io::Result<()> {
    let mut file = OpenOptions::new().create(true).append(true).open(path)?;
    for (key, value) in pairs {
        writeln!(file, "{key}={value}")?;
    }
    Ok(())
}

fn write_stdout_json(pairs: &[(&str, String)]) -> io::Result<()> {
    let map: BTreeMap<&str, &str> = pairs.iter().map(|(k, v)| (*k, v.as_str())).collect();
    let json = serde_json::to_string(&map).map_err(|e| io::Error::other(e.to_string()))?;
    println!("{json}");
    Ok(())
}

fn write_outputs(sink: OutputSink, pairs: &[(&str, String)]) -> io::Result<()> {
    match sink {
        OutputSink::GithubOutput => match std::env::var("GITHUB_OUTPUT") {
            Ok(path) => append_kv_file(&path, pairs),
            Err(_) => write_stdout_json(pairs),
        },
        OutputSink::GitlabDotenv => match std::env::var("DESLICER_DOTENV_PATH") {
            Ok(path) => append_kv_file(&path, pairs),
            Err(_) => write_stdout_json(pairs),
        },
        OutputSink::AzureLogging => {
            let mut stdout = io::stdout();
            for (key, value) in pairs {
                writeln!(stdout, "{}", format_azure_line(key, value))?;
            }
            Ok(())
        }
        OutputSink::BitbucketStorage => {
            let dir = std::env::var("BITBUCKET_PIPE_STORAGE_DIR").unwrap_or_else(|_| ".".into());
            let path = PathBuf::from(dir).join("deslicer-output.env");
            append_kv_file(&path.to_string_lossy(), pairs)
        }
        OutputSink::Stdout => write_stdout_json(pairs),
    }
}

fn emit_to_sink(pairs: &[(&str, String)]) -> i32 {
    let platform = detect_platform(None);
    let sink = detect_sink(platform);
    match write_outputs(sink, pairs) {
        Ok(()) => 0,
        Err(e) => {
            eprintln!("output write failed: {e}");
            1
        }
    }
}

pub fn emit_message(key_values: &[(&str, String)]) -> i32 {
    emit_to_sink(key_values)
}

pub fn emit_change_plan(plan: &ChangePlan) -> i32 {
    println!("{}", serde_json::to_string(plan).unwrap_or_default());
    let pairs = [
        ("plan_id", plan.id.clone()),
        ("plan_status", plan.status.clone()),
        ("plan_summary", plan.summary.clone().unwrap_or_default()),
    ];
    emit_to_sink(&pairs)
}

pub fn emit_plan_progress(progress: &PlanProgress) -> i32 {
    println!("{}", serde_json::to_string(progress).unwrap_or_default());
    let pairs = [
        ("plan_id", progress.id.clone()),
        ("plan_status", progress.status.clone()),
    ];
    emit_to_sink(&pairs)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Mutex;

    static ENV_LOCK: Mutex<()> = Mutex::new(());

    #[test]
    fn format_azure_line_sets_task_variable() {
        let line = format_azure_line("plan_id", "abc-123");
        assert_eq!(line, "##vso[task.setvariable variable=plan_id]abc-123");
    }

    #[test]
    fn github_output_appends_kv() {
        let _guard = ENV_LOCK.lock().unwrap();
        let file = tempfile::NamedTempFile::new().unwrap();
        let path = file.path().to_string_lossy().into_owned();
        std::env::set_var("GITHUB_OUTPUT", &path);
        let pairs = [("plan_id", "gh-plan".to_string())];
        write_outputs(OutputSink::GithubOutput, &pairs).unwrap();
        std::env::remove_var("GITHUB_OUTPUT");
        let content = std::fs::read_to_string(&path).unwrap();
        assert!(content.contains("plan_id=gh-plan"));
    }

    #[test]
    fn gitlab_dotenv_appends_kv() {
        let _guard = ENV_LOCK.lock().unwrap();
        let file = tempfile::NamedTempFile::new().unwrap();
        let path = file.path().to_string_lossy().into_owned();
        std::env::set_var("DESLICER_DOTENV_PATH", &path);
        let pairs = [("plan_status", "approved".to_string())];
        write_outputs(OutputSink::GitlabDotenv, &pairs).unwrap();
        std::env::remove_var("DESLICER_DOTENV_PATH");
        let content = std::fs::read_to_string(&path).unwrap();
        assert!(content.contains("plan_status=approved"));
    }

    #[test]
    fn github_missing_env_falls_back_to_stdout_json() {
        let _guard = ENV_LOCK.lock().unwrap();
        std::env::remove_var("GITHUB_OUTPUT");
        let pairs = [("plan_id", "fallback".to_string())];
        write_outputs(OutputSink::GithubOutput, &pairs).unwrap();
    }
}