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}