Skip to main content

runx_cli/
payment.rs

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}