Skip to main content

indodax_cli/commands/
utility.rs

1use 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
149/// Splits a shell-style command line into argv-like tokens using shlex.
150fn 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        // shlex returns None on parse error (unclosed quotes), unwrap_or_default gives empty vec
240        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}