indodax_cli/commands/
utility.rs1use std::collections::HashMap;
2use crate::client::IndodaxClient;
3use crate::config::ResolvedCredentials;
4use crate::output::CommandOutput;
5use anyhow::Result;
6
7#[derive(Debug, clap::Subcommand)]
8pub enum UtilityCommand {
9 #[command(name = "setup", about = "Interactive setup wizard")]
10 Setup,
11
12 #[command(name = "shell", about = "Start interactive REPL")]
13 Shell,
14}
15
16pub async fn execute(
17 _client: &IndodaxClient,
18 _creds: &Option<ResolvedCredentials>,
19 cmd: &UtilityCommand,
20) -> Result<CommandOutput> {
21 match cmd {
22 UtilityCommand::Setup => setup().await,
23 UtilityCommand::Shell => shell().await,
24 }
25}
26
27async fn test_credentials(api_key: &str, api_secret: &str) {
28 use crate::auth::Signer;
29 let signer = Signer::new(api_key, api_secret);
30 match IndodaxClient::new(Some(signer)) {
31 Ok(client) => {
32 match client.private_post_v1::<serde_json::Value>("getInfo", &HashMap::new()).await {
33 Ok(info) => {
34 let name = info.get("name").and_then(|v| v.as_str()).unwrap_or("unknown");
35 let user_id = info.get("user_id").and_then(|v| v.as_str()).unwrap_or("unknown");
36 eprintln!(" Credentials validated: logged in as '{}' (user ID: {})", name, user_id);
37 }
38 Err(e) => {
39 eprintln!(" Warning: Credentials saved but validation failed: {}", e);
40 eprintln!(" Check that your API key and secret are correct.");
41 }
42 }
43 }
44 Err(e) => {
45 eprintln!(" Warning: Could not create client for validation: {}", e);
46 }
47 }
48}
49
50async fn setup() -> Result<CommandOutput> {
51 use dialoguer::{Confirm, Input, Password};
52
53 eprintln!("=== Indodax CLI Setup Wizard ===\n");
54
55 let api_key: String = Input::new()
56 .with_prompt("Enter your Indodax API key")
57 .interact_text()?;
58
59 let api_secret: String = Password::new()
60 .with_prompt("Enter your Indodax API secret")
61 .interact()?;
62
63 let callback_url: String = Input::new()
64 .with_prompt("Enter your Indodax Callback URL (optional, e.g., https://indodax.tep2.in/)")
65 .allow_empty(true)
66 .interact_text()?;
67
68 let save: bool = Confirm::new()
69 .with_prompt("Save configuration to config?")
70 .default(true)
71 .interact()?;
72
73 if save {
74 let mut config = crate::config::IndodaxConfig::load()?;
75 config.api_key = Some(crate::config::SecretValue::new(&api_key));
76 config.api_secret = Some(crate::config::SecretValue::new(&api_secret));
77 if !callback_url.is_empty() {
78 config.callback_url = Some(callback_url);
79 }
80 config.save()?;
81 eprintln!("\nConfiguration saved to {:?}", crate::config::IndodaxConfig::config_path());
82 }
83
84 eprintln!("\nValidating credentials...");
85 test_credentials(&api_key, &api_secret).await;
86
87 let data = serde_json::json!({
88 "status": "ok",
89 "message": "Setup complete"
90 });
91 Ok(CommandOutput::json(data))
92}
93
94async fn shell() -> Result<CommandOutput> {
95 use crate::Cli;
96 use clap::Parser;
97 use rustyline::DefaultEditor;
98
99 println!("Indodax CLI interactive shell");
100 println!("Type commands without 'indodax' prefix (e.g. 'market ticker btc_idr')");
101 println!("Type 'help' for available commands, 'exit' to quit\n");
102
103 let mut rl = DefaultEditor::new()?;
104 let mut config = crate::config::IndodaxConfig::load()?;
105 let creds = config.resolve_credentials(None, None)?;
106 let signer = creds.as_ref().map(|c| {
107 crate::auth::Signer::new(c.api_key.as_str(), c.api_secret.as_str())
108 });
109let client = crate::client::IndodaxClient::new(signer)?;
110 let client_ref = &client;
111
112 loop {
113 let line = rl.readline("indodax> ");
114 match line {
115 Ok(input) if input.trim().is_empty() => continue,
116 Ok(input) if input.trim() == "exit" || input.trim() == "quit" => break,
117 Ok(input) => {
118 let _ = rl.add_history_entry(&input);
119 let args = format!("indodax {}", input);
120 let args: Vec<String> = shell_parse(&args);
121 match Cli::try_parse_from(args) {
122 Ok(cli) => {
123 if matches!(cli.command, crate::Command::Shell) {
124 println!("Already in shell mode");
125 continue;
126 }
127 if matches!(cli.command, crate::Command::Setup) {
128 println!("Setup is only available from the command line, not inside the shell");
129 continue;
130 }
131 match crate::dispatch(cli, client_ref, &mut config).await {
132 Ok(output) => println!("{}", output.render()),
133 Err(e) => {
134 eprintln!("Error: {}", e);
135 }
136 }
137 }
138 Err(e) => eprintln!("{}", e.render()),
139 }
140 }
141 Err(_) => break,
142 }
143 }
144
145 let data = serde_json::json!({"status": "exited"});
146 Ok(CommandOutput::json(data))
147}
148
149fn shell_parse(input: &str) -> Vec<String> {
151 shlex::split(input).unwrap_or_default()
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 #[test]
159 fn test_shell_parse_simple() {
160 let result = shell_parse("market ticker btc_idr");
161 assert_eq!(result, vec!["market", "ticker", "btc_idr"]);
162 }
163
164 #[test]
165 fn test_shell_parse_single_word() {
166 let result = shell_parse("help");
167 assert_eq!(result, vec!["help"]);
168 }
169
170 #[test]
171 fn test_shell_parse_empty() {
172 let result = shell_parse("");
173 assert!(result.is_empty());
174 }
175
176 #[test]
177 fn test_shell_parse_with_quotes() {
178 let result =
179 shell_parse(r#"auth set --api-key "my key" --api-secret "my secret""#);
180 assert_eq!(
181 result,
182 vec![
183 "auth", "set", "--api-key", "my key", "--api-secret", "my secret",
184 ]
185 );
186 }
187
188 #[test]
189 fn test_shell_parse_quoted_value_with_dash() {
190 let result = shell_parse(r#"market ticker --pair "btc_idr""#);
191 assert_eq!(result, vec!["market", "ticker", "--pair", "btc_idr"]);
192 }
193
194 #[test]
195 fn test_shell_parse_multiple_spaces() {
196 let result = shell_parse("market ticker btc_idr");
197 assert_eq!(result, vec!["market", "ticker", "btc_idr"]);
198 }
199
200 #[test]
201 fn test_shell_parse_leading_trailing_spaces() {
202 let result = shell_parse(" market ticker btc_idr ");
203 assert_eq!(result, vec!["market", "ticker", "btc_idr"]);
204 }
205
206 #[test]
207 fn test_shell_parse_only_whitespace() {
208 let result = shell_parse(" ");
209 assert!(result.is_empty());
210 }
211
212 #[test]
213 fn test_shell_parse_quoted_empty_string() {
214 let result = shell_parse(r#"set key """#);
215 assert_eq!(result, vec!["set", "key", ""]);
216 }
217
218 #[test]
219 fn test_shell_parse_quoted_whitespace_only() {
220 let result = shell_parse(r#"echo " ""#);
221 assert_eq!(result, vec!["echo", " "]);
222 }
223
224 #[test]
225 fn test_shell_parse_escaped_quote_inside_quotes() {
226 let result = shell_parse(r#"echo "he said \"hi\"""#);
227 assert_eq!(result, vec!["echo", r#"he said "hi""#]);
228 }
229
230 #[test]
231 fn test_shell_parse_escaped_backslash_inside_quotes() {
232 let result = shell_parse(r#"path "a\\b""#);
233 assert_eq!(result, vec!["path", r#"a\b"#]);
234 }
235
236 #[test]
237 fn test_shell_parse_unclosed_quote_returns_empty() {
238 let result = shell_parse(r#"foo "bar baz"#);
239 assert!(result.is_empty());
241 }
242
243 #[test]
244 fn test_shell_parse_adjacent_quoted_and_bare() {
245 let result = shell_parse(r#"x="hello world""#);
246 assert_eq!(result, vec!["x=hello world"]);
247 }
248
249 #[test]
250 fn test_shell_parse_tab_separator() {
251 let result = shell_parse("a\tb\tc");
252 assert_eq!(result, vec!["a", "b", "c"]);
253 }
254
255 #[test]
256 fn test_utility_command_variants() {
257 let _cmd1 = UtilityCommand::Setup;
258 let _cmd2 = UtilityCommand::Shell;
259 }
260
261 #[test]
262 fn test_shell_parse_with_dash_args() {
263 let result = shell_parse("account balance -v");
264 assert_eq!(result, vec!["account", "balance", "-v"]);
265 }
266}