1use clap::Parser;
17use nautilus_persistence::backend::migration::parse_storage_option;
18
19#[derive(Debug, Parser)]
21#[clap(version, about, author)]
22pub struct NautilusCli {
23 #[clap(subcommand)]
24 pub(crate) command: Commands,
25}
26
27#[derive(Parser, Debug)]
29pub enum Commands {
30 Database(DatabaseOpt),
31 Catalog(CatalogOpt),
32 #[cfg(feature = "defi")]
33 Blockchain(BlockchainOpt),
34}
35
36#[derive(Parser, Debug)]
38#[command(about = "Postgres database operations", long_about = None)]
39pub struct DatabaseOpt {
40 #[clap(subcommand)]
41 pub(crate) command: DatabaseCommand,
42}
43
44#[derive(Parser, Debug, Clone)]
46pub struct DatabaseConfig {
47 #[arg(long)]
49 pub(crate) host: Option<String>,
50 #[arg(long)]
52 pub(crate) port: Option<u16>,
53 #[arg(long)]
55 pub(crate) username: Option<String>,
56 #[arg(long)]
58 pub(crate) database: Option<String>,
59 #[arg(long)]
61 pub(crate) password: Option<String>,
62 #[arg(long)]
64 pub(crate) schema: Option<String>,
65}
66
67#[derive(Parser, Debug, Clone)]
69#[command(about = "Postgres database operations", long_about = None)]
70pub enum DatabaseCommand {
71 Init(DatabaseConfig),
73 Drop(DatabaseConfig),
75}
76
77#[cfg(feature = "defi")]
78#[derive(Parser, Debug)]
80#[command(about = "Blockchain operations", long_about = None)]
81pub struct BlockchainOpt {
82 #[clap(subcommand)]
83 pub(crate) command: BlockchainCommand,
84}
85
86#[cfg(feature = "defi")]
87#[derive(Parser, Debug, Clone)]
89#[command(about = "Blockchain operations", long_about = None)]
90pub enum BlockchainCommand {
91 SyncBlocks {
93 #[arg(long)]
95 chain: String,
96 #[arg(long)]
98 from_block: Option<u64>,
99 #[arg(long)]
101 to_block: Option<u64>,
102 #[clap(flatten)]
104 database: DatabaseConfig,
105 },
106 SyncDex {
108 #[arg(long)]
110 chain: String,
111 #[arg(long)]
113 dex: String,
114 #[arg(long)]
116 rpc_url: Option<String>,
117 #[arg(long)]
119 reset: bool,
120 #[arg(long)]
122 multicall_calls_per_rpc_request: Option<u32>,
123 #[clap(flatten)]
125 database: DatabaseConfig,
126 },
127 AnalyzePool {
129 #[arg(long)]
131 chain: String,
132 #[arg(long)]
134 dex: String,
135 #[arg(long)]
137 address: String,
138 #[arg(long)]
140 from_block: Option<u64>,
141 #[arg(long)]
143 to_block: Option<u64>,
144 #[expect(
146 clippy::doc_markdown,
147 reason = "clap renders doc comments as plain help text"
148 )]
149 #[arg(long)]
150 rpc_url: Option<String>,
151 #[arg(long)]
153 reset: bool,
154 #[expect(
156 clippy::doc_markdown,
157 reason = "clap renders doc comments as plain help text"
158 )]
159 #[arg(long)]
160 require_existing_snapshot: bool,
161 #[arg(long, value_delimiter = ',')]
163 checkpoint_blocks: Vec<u64>,
164 #[arg(long)]
166 skip_validation: bool,
167 #[arg(long)]
169 snapshot_from_rpc: bool,
170 #[arg(long)]
172 multicall_calls_per_rpc_request: Option<u32>,
173 #[clap(flatten)]
175 database: DatabaseConfig,
176 },
177 AnalyzePools {
179 #[arg(long)]
181 chain: String,
182 #[arg(long)]
184 dex: String,
185 #[arg(long = "address")]
187 addresses: Vec<String>,
188 #[arg(long)]
190 addresses_file: Option<String>,
191 #[arg(long)]
193 from_block: Option<u64>,
194 #[arg(long)]
196 to_block: Option<u64>,
197 #[expect(
199 clippy::doc_markdown,
200 reason = "clap renders doc comments as plain help text"
201 )]
202 #[arg(long)]
203 rpc_url: Option<String>,
204 #[arg(long)]
206 reset: bool,
207 #[expect(
209 clippy::doc_markdown,
210 reason = "clap renders doc comments as plain help text"
211 )]
212 #[arg(long)]
213 require_existing_snapshot: bool,
214 #[arg(long, value_delimiter = ',')]
216 checkpoint_blocks: Vec<u64>,
217 #[arg(long)]
219 skip_validation: bool,
220 #[arg(long)]
222 snapshot_from_rpc: bool,
223 #[arg(long)]
225 concurrency: Option<usize>,
226 #[arg(long)]
228 multicall_calls_per_rpc_request: Option<u32>,
229 #[clap(flatten)]
231 database: DatabaseConfig,
232 },
233}
234
235#[cfg(all(test, feature = "defi"))]
236mod tests {
237 use clap::Parser;
238 use rstest::rstest;
239
240 use super::*;
241
242 #[rstest]
243 fn analyze_pools_cli_parses_repeated_addresses_file_and_shared_options() {
244 let cli = NautilusCli::try_parse_from([
245 "nautilus",
246 "blockchain",
247 "analyze-pools",
248 "--chain",
249 "ethereum",
250 "--dex",
251 "UniswapV3",
252 "--address",
253 "0x1111111111111111111111111111111111111111",
254 "--address",
255 "0x2222222222222222222222222222222222222222",
256 "--addresses-file",
257 "/tmp/pools.txt",
258 "--from-block",
259 "100",
260 "--to-block",
261 "200",
262 "--rpc-url",
263 "http://localhost:8545",
264 "--reset",
265 "--require-existing-snapshot",
266 "--multicall-calls-per-rpc-request",
267 "25",
268 "--host",
269 "localhost",
270 "--port",
271 "5433",
272 "--username",
273 "postgres",
274 "--database",
275 "nautilus",
276 "--password",
277 "secret",
278 ])
279 .unwrap();
280
281 match cli.command {
282 Commands::Blockchain(BlockchainOpt {
283 command:
284 BlockchainCommand::AnalyzePools {
285 chain,
286 dex,
287 addresses,
288 addresses_file,
289 from_block,
290 to_block,
291 rpc_url,
292 reset,
293 require_existing_snapshot,
294 checkpoint_blocks,
295 skip_validation,
296 snapshot_from_rpc,
297 concurrency,
298 multicall_calls_per_rpc_request,
299 database,
300 },
301 }) => {
302 assert_eq!(chain, "ethereum");
303 assert_eq!(dex, "UniswapV3");
304 assert_eq!(
305 addresses,
306 vec![
307 "0x1111111111111111111111111111111111111111".to_string(),
308 "0x2222222222222222222222222222222222222222".to_string(),
309 ]
310 );
311 assert_eq!(addresses_file.as_deref(), Some("/tmp/pools.txt"));
312 assert_eq!(from_block, Some(100));
313 assert_eq!(to_block, Some(200));
314 assert_eq!(rpc_url.as_deref(), Some("http://localhost:8545"));
315 assert!(reset);
316 assert!(require_existing_snapshot);
317 assert!(checkpoint_blocks.is_empty());
318 assert!(!skip_validation);
319 assert!(!snapshot_from_rpc);
320 assert_eq!(concurrency, None);
321 assert_eq!(multicall_calls_per_rpc_request, Some(25));
322 assert_eq!(database.host.as_deref(), Some("localhost"));
323 assert_eq!(database.port, Some(5433));
324 assert_eq!(database.username.as_deref(), Some("postgres"));
325 assert_eq!(database.database.as_deref(), Some("nautilus"));
326 assert_eq!(database.password.as_deref(), Some("secret"));
327 assert_eq!(database.schema, None);
328 }
329 _ => panic!("Expected analyze-pools blockchain command"),
330 }
331 }
332
333 #[rstest]
334 #[case("analyze-pool")]
335 #[case("analyze-pools")]
336 fn blockchain_analysis_help_lists_capabilities_as_plain_text(#[case] subcommand: &str) {
337 let mut command = crate::cli_command();
338 let help = command
339 .find_subcommand_mut("blockchain")
340 .and_then(|command| command.find_subcommand_mut(subcommand))
341 .map(|command| command.render_long_help().to_string())
342 .unwrap();
343
344 assert!(help.contains("UniswapV3"));
346 assert!(help.contains("PancakeSwapV3"));
347 assert!(help.contains("AerodromeSlipstream"));
348 assert!(!help.contains("SushiSwapV2"));
349 assert!(help.contains("RPC_HTTP_URL"));
350 assert!(help.contains("needs_bootstrap"));
351 assert!(!help.contains("`UniswapV3`"));
353 assert!(!help.contains("`PancakeSwapV3`"));
354 assert!(!help.contains("`RPC_HTTP_URL`"));
355 assert!(!help.contains("`needs_bootstrap`"));
356 }
357
358 #[rstest]
359 fn blockchain_sync_dex_help_lists_discoverable_dexes() {
360 let mut command = crate::cli_command();
361 let help = command
362 .find_subcommand_mut("blockchain")
363 .and_then(|command| command.find_subcommand_mut("sync-dex"))
364 .map(|command| command.render_long_help().to_string())
365 .unwrap();
366
367 assert!(help.contains("Discoverable DEXes"));
369 assert!(!help.contains("Snapshot-capable"));
370 assert!(help.contains("UniswapV2"));
372 }
373}
374
375#[derive(Debug, Parser)]
377pub struct CatalogOpt {
378 #[clap(subcommand)]
379 pub(crate) command: CatalogCommand,
380}
381
382#[derive(Debug, Parser)]
384pub enum CatalogCommand {
385 MigrateParquet(CatalogMigrationOpt),
386}
387
388#[derive(Debug, Parser)]
390pub struct CatalogMigrationOpt {
391 pub(crate) source: String,
393 pub(crate) destination: String,
395 #[arg(long)]
397 pub(crate) dry_run: bool,
398 #[arg(long = "source-option", value_parser = parse_storage_option)]
400 pub(crate) source_options: Vec<(String, String)>,
401 #[arg(long = "target-option", value_parser = parse_storage_option)]
403 pub(crate) target_options: Vec<(String, String)>,
404}