1use std::collections::BTreeMap;
2use std::env;
3use std::fmt;
4use std::fs;
5use std::io::{self, Read};
6use std::path::{Path, PathBuf};
7use std::process::ExitCode;
8
9use runx_pay::{
10 PaymentAdmissionError, PaymentAdmissionIssueResponse, PaymentAdmissionRequest,
11 PaymentAdmissionSigner,
12};
13use serde::Serialize;
14
15pub const RUNX_PAYMENT_ADMISSION_KID_ENV: &str = "RUNX_PAYMENT_ADMISSION_KID";
16pub const RUNX_PAYMENT_ADMISSION_SIGNING_KEY_ENV: &str = "RUNX_PAYMENT_ADMISSION_SIGNING_KEY";
17
18#[derive(Clone, Debug, Eq, PartialEq)]
19pub struct PaymentPlan {
20 pub action: PaymentAction,
21}
22
23#[derive(Clone, Debug, Eq, PartialEq)]
24pub enum PaymentAction {
25 IssueAdmission(PaymentAdmissionPlan),
26}
27
28#[derive(Clone, Debug, Eq, PartialEq)]
29pub struct PaymentAdmissionPlan {
30 pub input: PaymentInputSource,
31 pub json: bool,
32}
33
34#[derive(Clone, Debug, Eq, PartialEq)]
35pub enum PaymentInputSource {
36 Path(PathBuf),
37 Stdin,
38}
39
40pub fn run_native_payment(plan: PaymentPlan) -> ExitCode {
41 let cwd = match env::current_dir() {
42 Ok(cwd) => cwd,
43 Err(error) => {
44 let error = PaymentCliError::CurrentDirectory(error);
45 return write_error(&error, true);
46 }
47 };
48
49 match run_payment_command(&plan, &crate::cli_io::env_map(), &cwd) {
50 Ok(output) => crate::cli_io::write_stdout_code(&output.stdout, output.exit_code),
51 Err(error) => write_error(&error, true),
52 }
53}
54
55pub fn run_payment_command(
56 plan: &PaymentPlan,
57 env: &BTreeMap<String, String>,
58 cwd: &Path,
59) -> Result<PaymentCliOutput, PaymentCliError> {
60 match &plan.action {
61 PaymentAction::IssueAdmission(issue) => issue_admission(issue, env, cwd),
62 }
63}
64
65fn issue_admission(
66 plan: &PaymentAdmissionPlan,
67 env: &BTreeMap<String, String>,
68 cwd: &Path,
69) -> Result<PaymentCliOutput, PaymentCliError> {
70 if !plan.json {
71 return Err(PaymentCliError::InvalidArgs(
72 "runx payment admission issue requires --json".to_owned(),
73 ));
74 }
75 let raw = read_payment_input(&plan.input, env, cwd)?;
76 let request: PaymentAdmissionRequest =
77 serde_json::from_str(&raw).map_err(PaymentCliError::ParseInput)?;
78 let kid = non_empty_env(env, RUNX_PAYMENT_ADMISSION_KID_ENV)
79 .ok_or(PaymentCliError::MissingSigningEnv)?;
80 let seed = non_empty_env(env, RUNX_PAYMENT_ADMISSION_SIGNING_KEY_ENV)
81 .ok_or(PaymentCliError::MissingSigningEnv)?;
82 let signer = PaymentAdmissionSigner::from_seed_base64(kid, seed)?;
83 let result = signer.issue(&request)?;
84 let stdout = serde_json::to_string_pretty(&PaymentJsonEnvelope {
85 status: "success",
86 result: &result,
87 })
88 .map(|json| format!("{json}\n"))
89 .map_err(PaymentCliError::Serialize)?;
90 Ok(PaymentCliOutput {
91 stdout,
92 exit_code: 0,
93 })
94}
95
96#[derive(Debug)]
97pub struct PaymentCliOutput {
98 pub stdout: String,
99 pub exit_code: u8,
100}
101
102#[derive(Debug)]
103pub enum PaymentCliError {
104 CurrentDirectory(io::Error),
105 InvalidArgs(String),
106 MissingSigningEnv,
107 Read(PathBuf, io::Error),
108 ReadStdin(io::Error),
109 ParseInput(serde_json::Error),
110 Admission(PaymentAdmissionError),
111 Serialize(serde_json::Error),
112}
113
114impl PaymentCliError {
115 fn code(&self) -> &'static str {
116 match self {
117 Self::CurrentDirectory(_) => "current_directory",
118 Self::InvalidArgs(_) => "invalid_args",
119 Self::MissingSigningEnv => "missing_signing_env",
120 Self::Read(_, _) => "read_input",
121 Self::ReadStdin(_) => "read_stdin",
122 Self::ParseInput(_) => "parse_input",
123 Self::Admission(_) => "payment_admission",
124 Self::Serialize(_) => "serialize_output",
125 }
126 }
127
128 fn exit_code(&self) -> u8 {
129 match self {
130 Self::InvalidArgs(_) => 64,
131 Self::CurrentDirectory(_)
132 | Self::MissingSigningEnv
133 | Self::Read(_, _)
134 | Self::ReadStdin(_)
135 | Self::ParseInput(_)
136 | Self::Admission(_)
137 | Self::Serialize(_) => 1,
138 }
139 }
140}
141
142impl fmt::Display for PaymentCliError {
143 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
144 match self {
145 Self::CurrentDirectory(error) => write!(formatter, "failed to resolve cwd: {error}"),
146 Self::InvalidArgs(message) => formatter.write_str(message),
147 Self::MissingSigningEnv => write!(
148 formatter,
149 "runx payment admission issue requires {RUNX_PAYMENT_ADMISSION_KID_ENV} and {RUNX_PAYMENT_ADMISSION_SIGNING_KEY_ENV}",
150 ),
151 Self::Read(path, error) => {
152 write!(
153 formatter,
154 "failed to read payment admission input {}: {error}",
155 path.display()
156 )
157 }
158 Self::ReadStdin(error) => {
159 write!(
160 formatter,
161 "failed to read payment admission input stdin: {error}"
162 )
163 }
164 Self::ParseInput(error) => write!(
165 formatter,
166 "failed to parse payment admission input: {error}"
167 ),
168 Self::Admission(error) => write!(formatter, "{error}"),
169 Self::Serialize(error) => write!(
170 formatter,
171 "failed to serialize payment admission output: {error}"
172 ),
173 }
174 }
175}
176
177impl std::error::Error for PaymentCliError {}
178
179impl From<PaymentAdmissionError> for PaymentCliError {
180 fn from(error: PaymentAdmissionError) -> Self {
181 Self::Admission(error)
182 }
183}
184
185#[derive(Serialize)]
186struct PaymentJsonEnvelope<'a> {
187 status: &'static str,
188 result: &'a PaymentAdmissionIssueResponse,
189}
190
191#[derive(Serialize)]
192struct PaymentJsonError<'a> {
193 status: &'static str,
194 code: &'static str,
195 message: &'a str,
196}
197
198fn read_payment_input(
199 source: &PaymentInputSource,
200 env: &BTreeMap<String, String>,
201 cwd: &Path,
202) -> Result<String, PaymentCliError> {
203 match source {
204 PaymentInputSource::Path(path) => {
205 let resolved = resolve_payment_path(path, env, cwd);
206 fs::read_to_string(&resolved).map_err(|error| PaymentCliError::Read(resolved, error))
207 }
208 PaymentInputSource::Stdin => {
209 let mut raw = String::new();
210 io::stdin()
211 .read_to_string(&mut raw)
212 .map_err(PaymentCliError::ReadStdin)?;
213 Ok(raw)
214 }
215 }
216}
217
218fn resolve_payment_path(path: &Path, env: &BTreeMap<String, String>, cwd: &Path) -> PathBuf {
219 if path.is_absolute() {
220 return path.to_path_buf();
221 }
222 env.get("RUNX_CWD")
223 .map(PathBuf::from)
224 .or_else(|| env.get("INIT_CWD").map(PathBuf::from))
225 .unwrap_or_else(|| cwd.to_path_buf())
226 .join(path)
227}
228
229fn write_error(error: &PaymentCliError, json: bool) -> ExitCode {
230 if json {
231 let message = error.to_string();
232 match serde_json::to_string_pretty(&PaymentJsonError {
233 status: "error",
234 code: error.code(),
235 message: &message,
236 }) {
237 Ok(body) => {
238 return crate::cli_io::write_stdout_code(&format!("{body}\n"), error.exit_code());
239 }
240 Err(serialize_error) => {
241 let _ignored = crate::cli_io::write_stderr_code(&format!(
242 "runx: failed to serialize payment admission error: {serialize_error}\n"
243 ));
244 return ExitCode::from(1);
245 }
246 }
247 }
248
249 let _ignored = crate::cli_io::write_stderr_code(&format!("runx: {error}\n"));
250 ExitCode::from(error.exit_code())
251}
252
253fn non_empty_env<'a>(env: &'a BTreeMap<String, String>, key: &str) -> Option<&'a str> {
254 env.get(key)
255 .map(String::as_str)
256 .filter(|value| !value.trim().is_empty())
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 #[test]
264 fn issue_admission_requires_signing_env() {
265 let plan = PaymentPlan {
266 action: PaymentAction::IssueAdmission(PaymentAdmissionPlan {
267 input: PaymentInputSource::Path(PathBuf::from("missing.json")),
268 json: true,
269 }),
270 };
271 let env = BTreeMap::new();
272 let result = run_payment_command(&plan, &env, Path::new("."));
273 assert!(matches!(result, Err(PaymentCliError::Read(_, _))));
274 }
275}