Skip to main content

deslicer_cli/
output.rs

1use crate::ci::{detect_platform, CiPlatform};
2use crate::observer_client::{ChangePlan, PlanProgress};
3use std::collections::BTreeMap;
4use std::fs::OpenOptions;
5use std::io::{self, Write};
6use std::path::PathBuf;
7
8#[derive(Debug, Clone, Copy)]
9pub enum OutputSink {
10    GithubOutput,
11    GitlabDotenv,
12    AzureLogging,
13    BitbucketStorage,
14    Stdout,
15}
16
17pub fn detect_sink(platform: CiPlatform) -> OutputSink {
18    match platform {
19        CiPlatform::Github => OutputSink::GithubOutput,
20        CiPlatform::Gitlab => OutputSink::GitlabDotenv,
21        CiPlatform::Azure => OutputSink::AzureLogging,
22        CiPlatform::Bitbucket => OutputSink::BitbucketStorage,
23        CiPlatform::Local => OutputSink::Stdout,
24    }
25}
26
27fn format_azure_line(key: &str, value: &str) -> String {
28    format!("##vso[task.setvariable variable={key}]{value}")
29}
30
31fn append_kv_file(path: &str, pairs: &[(&str, String)]) -> io::Result<()> {
32    let mut file = OpenOptions::new().create(true).append(true).open(path)?;
33    for (key, value) in pairs {
34        writeln!(file, "{key}={value}")?;
35    }
36    Ok(())
37}
38
39fn write_stdout_json(pairs: &[(&str, String)]) -> io::Result<()> {
40    let map: BTreeMap<&str, &str> = pairs.iter().map(|(k, v)| (*k, v.as_str())).collect();
41    let json = serde_json::to_string(&map).map_err(|e| io::Error::other(e.to_string()))?;
42    println!("{json}");
43    Ok(())
44}
45
46fn write_outputs(sink: OutputSink, pairs: &[(&str, String)]) -> io::Result<()> {
47    match sink {
48        OutputSink::GithubOutput => match std::env::var("GITHUB_OUTPUT") {
49            Ok(path) => append_kv_file(&path, pairs),
50            Err(_) => write_stdout_json(pairs),
51        },
52        OutputSink::GitlabDotenv => match std::env::var("DESLICER_DOTENV_PATH") {
53            Ok(path) => append_kv_file(&path, pairs),
54            Err(_) => write_stdout_json(pairs),
55        },
56        OutputSink::AzureLogging => {
57            let mut stdout = io::stdout();
58            for (key, value) in pairs {
59                writeln!(stdout, "{}", format_azure_line(key, value))?;
60            }
61            Ok(())
62        }
63        OutputSink::BitbucketStorage => {
64            let dir = std::env::var("BITBUCKET_PIPE_STORAGE_DIR").unwrap_or_else(|_| ".".into());
65            let path = PathBuf::from(dir).join("deslicer-output.env");
66            append_kv_file(&path.to_string_lossy(), pairs)
67        }
68        OutputSink::Stdout => write_stdout_json(pairs),
69    }
70}
71
72fn emit_to_sink(pairs: &[(&str, String)]) -> i32 {
73    let platform = detect_platform(None);
74    let sink = detect_sink(platform);
75    match write_outputs(sink, pairs) {
76        Ok(()) => 0,
77        Err(e) => {
78            eprintln!("output write failed: {e}");
79            1
80        }
81    }
82}
83
84pub fn emit_message(key_values: &[(&str, String)]) -> i32 {
85    emit_to_sink(key_values)
86}
87
88pub fn emit_change_plan(plan: &ChangePlan) -> i32 {
89    println!("{}", serde_json::to_string(plan).unwrap_or_default());
90    let pairs = [
91        ("plan_id", plan.id.clone()),
92        ("plan_status", plan.status.clone()),
93        ("plan_summary", plan.summary.clone().unwrap_or_default()),
94    ];
95    emit_to_sink(&pairs)
96}
97
98pub fn emit_plan_progress(progress: &PlanProgress) -> i32 {
99    println!("{}", serde_json::to_string(progress).unwrap_or_default());
100    let pairs = [
101        ("plan_id", progress.id.clone()),
102        ("plan_status", progress.status.clone()),
103    ];
104    emit_to_sink(&pairs)
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use std::sync::Mutex;
111
112    static ENV_LOCK: Mutex<()> = Mutex::new(());
113
114    #[test]
115    fn format_azure_line_sets_task_variable() {
116        let line = format_azure_line("plan_id", "abc-123");
117        assert_eq!(line, "##vso[task.setvariable variable=plan_id]abc-123");
118    }
119
120    #[test]
121    fn github_output_appends_kv() {
122        let _guard = ENV_LOCK.lock().unwrap();
123        let file = tempfile::NamedTempFile::new().unwrap();
124        let path = file.path().to_string_lossy().into_owned();
125        std::env::set_var("GITHUB_OUTPUT", &path);
126        let pairs = [("plan_id", "gh-plan".to_string())];
127        write_outputs(OutputSink::GithubOutput, &pairs).unwrap();
128        std::env::remove_var("GITHUB_OUTPUT");
129        let content = std::fs::read_to_string(&path).unwrap();
130        assert!(content.contains("plan_id=gh-plan"));
131    }
132
133    #[test]
134    fn gitlab_dotenv_appends_kv() {
135        let _guard = ENV_LOCK.lock().unwrap();
136        let file = tempfile::NamedTempFile::new().unwrap();
137        let path = file.path().to_string_lossy().into_owned();
138        std::env::set_var("DESLICER_DOTENV_PATH", &path);
139        let pairs = [("plan_status", "approved".to_string())];
140        write_outputs(OutputSink::GitlabDotenv, &pairs).unwrap();
141        std::env::remove_var("DESLICER_DOTENV_PATH");
142        let content = std::fs::read_to_string(&path).unwrap();
143        assert!(content.contains("plan_status=approved"));
144    }
145
146    #[test]
147    fn github_missing_env_falls_back_to_stdout_json() {
148        let _guard = ENV_LOCK.lock().unwrap();
149        std::env::remove_var("GITHUB_OUTPUT");
150        let pairs = [("plan_id", "fallback".to_string())];
151        write_outputs(OutputSink::GithubOutput, &pairs).unwrap();
152    }
153}