1use 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 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
64fn 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"#;