Skip to main content

sui_tool/db_shell/
shell.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Interactive shell loop for the db-shell command.
5
6use anyhow::bail;
7use base64::Engine;
8use rustyline::Editor;
9use rustyline::error::ReadlineError;
10use rustyline::history::FileHistory;
11use std::io::Write as _;
12use std::sync::Arc;
13
14use crate::db_shell::{
15    backend::Backend,
16    completion::ShellHelper,
17    vfs::{VfsPath, resolve_path},
18};
19
20const DEFAULT_LIMIT: usize = 30;
21
22pub fn run_shell(backend: Arc<dyn Backend>, initial_path: VfsPath) -> anyhow::Result<()> {
23    let helper = ShellHelper {
24        backend: backend.clone(),
25        cwd: initial_path.clone(),
26    };
27
28    let mut rl: Editor<ShellHelper, FileHistory> = Editor::new()?;
29    rl.set_helper(Some(helper));
30
31    let mut cwd = initial_path;
32
33    loop {
34        let prompt = format!("sui-db:{}> ", cwd);
35        // Keep the helper's CWD in sync so tab completion reflects the current directory.
36        if let Some(h) = rl.helper_mut() {
37            h.cwd = cwd.clone();
38        }
39        match rl.readline(&prompt) {
40            Ok(line_raw) => {
41                let line = line_raw.trim().to_string();
42                if line.is_empty() {
43                    continue;
44                }
45                let _ = rl.add_history_entry(&line);
46
47                match dispatch(&line, &mut cwd, &backend) {
48                    Ok(true) => break,
49                    Ok(false) => {}
50                    Err(e) => eprintln!("error: {e}"),
51                }
52            }
53            Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => break,
54            Err(e) => {
55                eprintln!("readline error: {e}");
56                break;
57            }
58        }
59    }
60
61    Ok(())
62}
63
64/// Returns Ok(true) to signal exit.
65fn dispatch(line: &str, cwd: &mut VfsPath, backend: &Arc<dyn Backend>) -> anyhow::Result<bool> {
66    let tokens: Vec<&str> = line.split_whitespace().collect();
67    if tokens.is_empty() {
68        return Ok(false);
69    }
70
71    match tokens[0] {
72        "exit" | "quit" | "q" => return Ok(true),
73        "pwd" => println!("{cwd}"),
74        "cd" => cmd_cd(tokens.get(1).copied(), cwd)?,
75        "ls" => cmd_ls(&tokens[1..], cwd, backend)?,
76        "cat" => cmd_cat(&tokens[1..], cwd, backend)?,
77        "dbg" => cmd_dbg(&tokens[1..], cwd, backend)?,
78        "bcs" => cmd_bcs(&tokens[1..], cwd, backend)?,
79        "rm" => cmd_rm(&tokens[1..], cwd, backend)?,
80        "help" => cmd_help(tokens.get(1).copied()),
81        _ => bail!(
82            "unknown command '{}' — type 'help' for a list of commands",
83            tokens[0]
84        ),
85    }
86
87    Ok(false)
88}
89
90fn cmd_cd(path_arg: Option<&str>, cwd: &mut VfsPath) -> anyhow::Result<()> {
91    let target = match path_arg {
92        None | Some("/") => VfsPath::Root,
93        Some("..") => cwd.parent().unwrap_or(VfsPath::Root),
94        Some(p) => resolve_path(cwd, p)?,
95    };
96    if !target.is_dir() {
97        bail!("'{}': not a directory", target);
98    }
99    *cwd = target;
100    Ok(())
101}
102
103fn cmd_ls(args: &[&str], cwd: &VfsPath, backend: &Arc<dyn Backend>) -> anyhow::Result<()> {
104    let mut limit = DEFAULT_LIMIT;
105    let mut path_arg: Option<&str> = None;
106
107    let mut i = 0;
108    while i < args.len() {
109        match args[i] {
110            "--limit" | "-l" => {
111                i += 1;
112                limit = args
113                    .get(i)
114                    .ok_or_else(|| anyhow::anyhow!("--limit requires a value"))?
115                    .parse()
116                    .map_err(|_| anyhow::anyhow!("--limit requires a number"))?;
117            }
118            arg if arg.starts_with('-') => bail!("unknown flag: {arg}"),
119            arg => path_arg = Some(arg),
120        }
121        i += 1;
122    }
123
124    let (target, use_cursor) = match path_arg {
125        None | Some(".") => (cwd.clone(), false),
126        Some(p) => {
127            let resolved = resolve_path(cwd, p)?;
128            let cursor = resolved.is_ls_cursor();
129            (resolved, cursor)
130        }
131    };
132
133    let entries = if use_cursor {
134        backend.ls_cursor(&target, limit)?
135    } else {
136        backend.ls_children(&target, limit)?
137    };
138
139    for e in &entries {
140        let display = if e.is_dir {
141            format!("{}/", e.name)
142        } else {
143            e.name.clone()
144        };
145        println!("{display}");
146    }
147
148    if entries.len() == limit {
149        println!("(limit of {limit} reached — use --limit N to show more)");
150    }
151
152    Ok(())
153}
154
155fn resolve_file_path(args: &[&str], cwd: &VfsPath) -> anyhow::Result<VfsPath> {
156    let path_str = args
157        .first()
158        .ok_or_else(|| anyhow::anyhow!("path required"))?;
159    resolve_path(cwd, path_str)
160}
161
162fn cmd_cat(args: &[&str], cwd: &VfsPath, backend: &Arc<dyn Backend>) -> anyhow::Result<()> {
163    let target = resolve_file_path(args, cwd)?;
164    let value = backend.read_json(&target)?;
165    println!("{}", serde_json::to_string_pretty(&value)?);
166    Ok(())
167}
168
169fn cmd_dbg(args: &[&str], cwd: &VfsPath, backend: &Arc<dyn Backend>) -> anyhow::Result<()> {
170    let target = resolve_file_path(args, cwd)?;
171    let text = backend.read_debug(&target)?;
172    println!("{text}");
173    Ok(())
174}
175
176fn cmd_bcs(args: &[&str], cwd: &VfsPath, backend: &Arc<dyn Backend>) -> anyhow::Result<()> {
177    let mut raw = false;
178    let mut path_arg: Option<&str> = None;
179
180    for arg in args {
181        match *arg {
182            "--raw" => raw = true,
183            a if a.starts_with('-') => bail!("unknown flag: {a}"),
184            a => path_arg = Some(a),
185        }
186    }
187
188    let target = match path_arg {
189        Some(p) => resolve_path(cwd, p)?,
190        None => bail!("path required"),
191    };
192
193    let bytes = backend.read_bcs(&target)?;
194
195    if raw {
196        std::io::stdout()
197            .write_all(&bytes)
198            .map_err(|e| anyhow::anyhow!("write error: {e}"))?;
199    } else {
200        println!(
201            "{}",
202            base64::engine::general_purpose::STANDARD.encode(&bytes)
203        );
204    }
205
206    Ok(())
207}
208
209fn cmd_rm(args: &[&str], cwd: &VfsPath, backend: &Arc<dyn Backend>) -> anyhow::Result<()> {
210    let target = resolve_file_path(args, cwd)?;
211    print!("Remove '{target}'? This is permanent. [y/N] ");
212    std::io::stdout().flush()?;
213
214    let mut answer = String::new();
215    std::io::stdin().read_line(&mut answer)?;
216    if answer.trim().eq_ignore_ascii_case("y") {
217        backend.delete(&target)?;
218        println!("deleted.");
219    } else {
220        println!("cancelled.");
221    }
222
223    Ok(())
224}
225
226fn cmd_help(topic: Option<&str>) {
227    match topic {
228        None => print_help_overview(),
229        Some("ls") => print!("{}", HELP_LS),
230        Some("cd") => print!("{}", HELP_CD),
231        Some("cat") => print!("{}", HELP_CAT),
232        Some("dbg") => print!("{}", HELP_DBG),
233        Some("bcs") => print!("{}", HELP_BCS),
234        Some("rm") => print!("{}", HELP_RM),
235        Some("pwd") => println!("pwd\n\n  Print the current working directory."),
236        Some(other) => println!("No help for '{other}'. Type 'help' for a command list."),
237    }
238}
239
240fn print_help_overview() {
241    println!(
242        r#"
243Available commands:
244
245  ls [path] [--limit N]   List directory contents
246  cd [path]               Change directory
247  cat <path>              Print JSON representation
248  dbg <path>              Print Rust debug representation
249  bcs [--raw] <path>      Print BCS (base64 by default, raw bytes with --raw)
250  rm <path>               Remove an entry (permanent!)
251  pwd                     Print current directory
252  help [command]          Show this help or command-specific help
253  exit | quit             Exit the shell
254
255Virtual filesystem structure:
256
257  /epochs/<epoch>/first-checkpoint         First checkpoint of the epoch
258  /epochs/<epoch>/last-checkpoint          Last checkpoint of the epoch
259  /epochs/<epoch>/committee                Validator committee for the epoch
260  /epochs/<epoch>/checkpoints/<seq>        Individual checkpoint in the epoch
261
262  /checkpoints/seq/<seq>/summary           Checkpoint summary by sequence number
263  /checkpoints/seq/<seq>/contents          Checkpoint contents by sequence number
264  /checkpoints/seq/<seq>/contents-short    tx/fx digest pairs, one per line
265  /checkpoints/digest/<digest>/summary     Checkpoint summary by digest
266  /checkpoints/digest/<digest>/contents    Checkpoint contents by digest
267  /checkpoints/digest/<digest>/contents-short  tx/fx digest pairs, one per line
268
269  /checkpoint-contents/<digest>            Raw checkpoint contents by contents digest
270
271  /transactions/<txdigest>                 A transaction
272  /transactions/<txdigest>.fx-<fxdigest>   Its effects
273
274  /consensus/commits/<index>/summary       Consensus commit summary with transaction keys
275
276Listing behaviour:
277
278  ls /checkpoints/seq             First 30 entries
279  ls /checkpoints/seq/1000        30 entries starting at seq 1000
280  ls /checkpoints/digest/Abc123   Digests matching that prefix (up to 30)
281
282  ls --limit 100 /epochs          Show up to 100 epochs
283"#
284    );
285}
286
287const HELP_LS: &str = r#"ls [path] [--limit N]
288
289  List the contents of a directory.
290
291  When `path` ends in a sequence number or digest (inside a paginated namespace
292  like /checkpoints/seq or /checkpoints/digest), it acts as a start cursor:
293  the listing begins there rather than listing that specific checkpoint's children.
294
295  To see the children of a specific checkpoint directory, cd into it first:
296    cd /checkpoints/seq/1234
297    ls           # shows: summary  contents
298
299Options:
300  --limit N    Maximum entries to show (default: 30)
301
302Examples:
303  ls                           List current directory
304  ls /epochs                   List known epochs
305  ls /checkpoints/seq          First 30 sequence-numbered checkpoints
306  ls /checkpoints/seq/5000     30 checkpoints from seq 5000
307  ls --limit 100 /epochs       Up to 100 epochs
308"#;
309
310const HELP_CD: &str = r#"cd [path]
311
312  Change the current working directory.
313
314  Supports absolute paths (/checkpoints/seq/1234), relative paths (../digest),
315  and .. to go up one level. 'cd' with no argument returns to root.
316"#;
317
318const HELP_CAT: &str = r#"cat <path>
319
320  Print the JSON representation of the entry at <path>.
321
322Examples:
323  cat /checkpoints/seq/1234/summary
324  cat /epochs/5/last-checkpoint
325  cat /epochs/5/committee
326"#;
327
328const HELP_DBG: &str = r#"dbg <path>
329
330  Print the Rust debug representation ({:#?}) of the entry at <path>.
331  Useful for inspecting raw field values not visible in the JSON view.
332"#;
333
334const HELP_BCS: &str = r#"bcs [--raw] <path>
335
336  Print the BCS serialization of the entry at <path>.
337  By default, prints base64-encoded bytes.
338  With --raw, writes raw binary bytes to stdout.
339
340Examples:
341  bcs /checkpoints/seq/1234/summary
342  bcs --raw /checkpoints/seq/1234/summary | xxd | head
343"#;
344
345const HELP_RM: &str = r#"rm <path>
346
347  Permanently delete the entry at <path>.
348  You will be prompted to confirm before deletion occurs.
349  THIS IS IRREVERSIBLE. Use only when the node is not running.
350"#;