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