soroban_cli/commands/tx/
edit.rs

1use std::{
2    env,
3    fs::{self},
4    io::{stdin, Cursor, IsTerminal, Write},
5    path::PathBuf,
6    process::{self},
7};
8
9use serde_json::json;
10use stellar_xdr::curr;
11
12use crate::{commands::global, print::Print};
13
14const SCHEMA_URL: &str =
15    "https://github.com/stellar/rs-stellar-xdr/raw/main/xdr-json/curr/TransactionEnvelope.json";
16
17#[derive(thiserror::Error, Debug)]
18pub enum Error {
19    #[error(transparent)]
20    Io(#[from] std::io::Error),
21
22    #[error(transparent)]
23    StellarXdr(#[from] stellar_xdr::curr::Error),
24
25    #[error(transparent)]
26    SerdeJson(#[from] serde_json::Error),
27
28    #[error(transparent)]
29    Base64Decode(#[from] base64::DecodeError),
30
31    #[error("Editor returned non-zero status")]
32    EditorNonZeroStatus,
33}
34
35// Command to edit the transaction
36/// e.g. `stellar tx new manage-data --data-name hello --build-only | stellar tx edit`
37#[derive(Debug, clap::Parser, Clone, Default)]
38#[group(skip)]
39pub struct Cmd {}
40
41impl Cmd {
42    pub fn run(&self, global_args: &global::Args) -> Result<(), Error> {
43        let print = Print::new(global_args.quiet);
44        let json: String = if stdin().is_terminal() {
45            default_json()
46        } else {
47            let mut input = String::new();
48            stdin().read_line(&mut input)?;
49            let input = input.trim();
50            xdr_to_json::<curr::TransactionEnvelope>(input)?
51        };
52
53        let path = tmp_file(&json)?;
54        let editor = get_editor();
55
56        open_editor(&print, &editor, &path)?;
57
58        let contents = fs::read_to_string(&path)?;
59        let xdr = json_to_xdr::<curr::TransactionEnvelope>(&contents)?;
60        fs::remove_file(&path)?;
61
62        println!("{xdr}");
63
64        Ok(())
65    }
66}
67
68struct Editor {
69    cmd: String,
70    source: String,
71    args: Vec<String>,
72}
73
74fn tmp_file(contents: &str) -> Result<PathBuf, Error> {
75    let temp_dir = env::current_dir().unwrap_or(env::temp_dir());
76    let file_name = format!("stellar-xdr-{}.json", rand::random::<u64>());
77    let path = temp_dir.join(file_name);
78
79    let mut file = fs::File::create(&path)?;
80    file.write_all(contents.as_bytes())?;
81
82    Ok(path)
83}
84
85fn get_editor() -> Editor {
86    let (source, cmd) = env::var("STELLAR_EDITOR")
87        .map(|val| ("STELLAR_EDITOR", val))
88        .or_else(|_| env::var("EDITOR").map(|val| ("EDITOR", val)))
89        .or_else(|_| env::var("VISUAL").map(|val| ("VISUAL", val)))
90        .unwrap_or_else(|_| ("default", "vim".to_string()));
91
92    let parts: Vec<&str> = cmd.split_whitespace().collect();
93    let cmd = parts[0].to_string();
94    let args = &parts[1..]
95        .iter()
96        .map(|&s| s.to_string())
97        .collect::<Vec<String>>();
98
99    Editor {
100        source: source.to_string(),
101        cmd,
102        args: args.clone(),
103    }
104}
105
106fn open_editor(print: &Print, editor: &Editor, path: &PathBuf) -> Result<(), Error> {
107    print.infoln(format!(
108        "Opening editor with `{source}=\"{cmd}\"`...",
109        source = editor.source,
110        cmd = editor.cmd,
111    ));
112
113    let mut binding = process::Command::new(editor.cmd.clone());
114    let command = binding.args(editor.args.clone()).arg(path);
115
116    // Windows doesn't have devices like /dev/tty.
117    #[cfg(unix)]
118    {
119        use fs::File;
120        let tty = File::open("/dev/tty")?;
121        let tty_out = fs::OpenOptions::new().write(true).open("/dev/tty")?;
122        let tty_err = fs::OpenOptions::new().write(true).open("/dev/tty")?;
123
124        command
125            .stdin(tty)
126            .stdout(tty_out)
127            .stderr(tty_err)
128            .env("TERM", "xterm-256color");
129    }
130
131    let status = command.spawn()?.wait()?;
132
133    if status.success() {
134        Ok(())
135    } else {
136        Err(Error::EditorNonZeroStatus)
137    }
138}
139
140fn xdr_to_json<T>(xdr_string: &str) -> Result<String, Error>
141where
142    T: curr::ReadXdr + serde::Serialize,
143{
144    let tx = T::from_xdr_base64(xdr_string, curr::Limits::none())?;
145    let mut schema: serde_json::Value = serde_json::to_value(tx)?;
146    schema["$schema"] = json!(SCHEMA_URL);
147    let json = serde_json::to_string_pretty(&schema)?;
148
149    Ok(json)
150}
151
152fn json_to_xdr<T>(json_string: &str) -> Result<String, Error>
153where
154    T: serde::de::DeserializeOwned + curr::WriteXdr,
155{
156    let mut schema: serde_json::Value = serde_json::from_str(json_string)?;
157
158    if let Some(obj) = schema.as_object_mut() {
159        obj.remove("$schema");
160    }
161
162    let json_string = serde_json::to_string(&schema)?;
163
164    let value: T = serde_json::from_str(json_string.as_str())?;
165    let mut data = Vec::new();
166    let cursor = Cursor::new(&mut data);
167    let mut limit = curr::Limited::new(cursor, curr::Limits::none());
168    value.write_xdr(&mut limit)?;
169
170    Ok(value.to_xdr_base64(curr::Limits::none())?)
171}
172
173fn default_json() -> String {
174    format!(
175        r#"{{
176  "$schema": "{SCHEMA_URL}",
177  "tx": {{
178    "tx": {{
179      "source_account": "",
180      "fee": 100,
181      "seq_num": 0,
182      "cond": "none",
183      "memo": "none",
184      "operations": [],
185      "ext": "v0"
186    }},
187    "signatures": []
188  }}
189}}
190"#
191    )
192}