1
use server::command::{Argument, CmdError, CmdResult, config::SelectColumn};
2
use sqlx::types::Uuid;
3
use std::collections::HashMap;
4
use std::fmt::Debug;
5
use std::future::Future;
6
use std::pin::Pin;
7
use thiserror::Error;
8

            
9
pub trait CliRunnable: Debug + Send {
10
    fn run<'a>(
11
        &'a self,
12
        args: &'a HashMap<&str, &Argument>,
13
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>>;
14
}
15

            
16
#[derive(Debug)]
17
pub struct ArgumentNode {
18
    pub name: String,
19
    pub comment: String,
20
    pub completions: Option<Box<dyn CliRunnable>>,
21
}
22

            
23
#[derive(Debug)]
24
pub struct CommandNode {
25
    pub name: String,
26
    pub comment: String,
27
    pub is_leaf: bool,
28
    pub subcommands: Vec<CommandNode>,
29
    pub arguments: Vec<ArgumentNode>,
30
}
31

            
32
#[derive(Debug, Error)]
33
pub enum CommandError {
34
    #[error("No such command: {0}")]
35
    Command(String),
36
    #[error("Arguments error: {0}")]
37
    Argument(String),
38
    #[error("Execution: {0}")]
39
    Execution(#[from] CmdError),
40
}
41

            
42
pub trait CliCommand: Debug + Send {
43
    fn node() -> CommandNode;
44
}
45

            
46
#[derive(Debug)]
47
pub struct CliGetConfig;
48

            
49
impl CliCommand for CliGetConfig {
50
508
    fn node() -> CommandNode {
51
508
        CommandNode {
52
508
            name: "get".to_string(),
53
508
            is_leaf: true,
54
508
            comment: "Print the value from config".to_string(),
55
508
            subcommands: vec![],
56
508
            arguments: vec![
57
508
                ArgumentNode {
58
508
                    name: "name".to_string(),
59
508
                    comment: "Variable name".to_string(),
60
508
                    completions: None,
61
508
                },
62
508
                ArgumentNode {
63
508
                    name: "print".to_string(),
64
508
                    comment: "Print return value".to_string(),
65
508
                    completions: None,
66
508
                },
67
508
            ],
68
508
        }
69
508
    }
70
}
71

            
72
#[derive(Debug)]
73
pub struct CliSetConfig;
74

            
75
impl CliCommand for CliSetConfig {
76
508
    fn node() -> CommandNode {
77
508
        CommandNode {
78
508
            name: "set".to_string(),
79
508
            is_leaf: true,
80
508
            comment: "Set the value in config".to_string(),
81
508
            subcommands: vec![],
82
508
            arguments: vec![
83
508
                ArgumentNode {
84
508
                    name: "name".to_string(),
85
508
                    comment: "Variable name".to_string(),
86
508
                    completions: None,
87
508
                },
88
508
                ArgumentNode {
89
508
                    name: "value".to_string(),
90
508
                    comment: "Value to set".to_string(),
91
508
                    completions: None,
92
508
                },
93
508
            ],
94
508
        }
95
508
    }
96
}
97

            
98
#[derive(Debug)]
99
pub struct CliVersion;
100

            
101
impl CliCommand for CliVersion {
102
508
    fn node() -> CommandNode {
103
508
        CommandNode {
104
508
            name: "version".to_string(),
105
508
            is_leaf: true,
106
508
            comment: "Print the software version".to_string(),
107
508
            subcommands: vec![],
108
508
            arguments: vec![],
109
508
        }
110
508
    }
111
}
112

            
113
#[derive(Debug)]
114
pub struct CliSelectColumn;
115

            
116
impl CliRunnable for CliSelectColumn {
117
1
    fn run<'a>(
118
1
        &'a self,
119
1
        args: &'a HashMap<&str, &Argument>,
120
1
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
121
1
        Box::pin(async move {
122
1
            match (args.get("field"), args.get("table")) {
123
                (Some(Argument::String(field)), Some(Argument::String(table))) => {
124
                    Ok(SelectColumn::new()
125
                        .field(field.clone())
126
                        .table(table.clone())
127
                        .run()
128
                        .await?)
129
                }
130
1
                _ => Err(CommandError::Argument(
131
1
                    "No column or table provided".to_string(),
132
1
                )),
133
            }
134
1
        })
135
1
    }
136
}
137

            
138
impl CliCommand for CliSelectColumn {
139
508
    fn node() -> CommandNode {
140
508
        CommandNode {
141
508
            name: "selcol".to_string(),
142
508
            is_leaf: true,
143
508
            comment: "Raw select of SQL table".to_string(),
144
508
            subcommands: vec![],
145
508
            arguments: vec![
146
508
                ArgumentNode {
147
508
                    name: "field".to_string(),
148
508
                    comment: "Field name".to_string(),
149
508
                    completions: None,
150
508
                },
151
508
                ArgumentNode {
152
508
                    name: "table".to_string(),
153
508
                    comment: "Table name".to_string(),
154
508
                    completions: None,
155
508
                },
156
508
            ],
157
508
        }
158
508
    }
159
}
160

            
161
#[derive(Debug)]
162
pub struct CliCommodityCreate;
163

            
164
impl CliCommand for CliCommodityCreate {
165
508
    fn node() -> CommandNode {
166
508
        CommandNode {
167
508
            name: "create".to_string(),
168
508
            is_leaf: true,
169
508
            comment: "Create new commodity".to_string(),
170
508
            subcommands: vec![],
171
508
            arguments: vec![
172
508
                ArgumentNode {
173
508
                    name: "symbol".to_string(),
174
508
                    comment: "The abbreviation (or symbol) of the commodity".to_string(),
175
508
                    completions: None,
176
508
                },
177
508
                ArgumentNode {
178
508
                    name: "name".to_string(),
179
508
                    comment: "Human-readable name of commodity".to_string(),
180
508
                    completions: None,
181
508
                },
182
508
            ],
183
508
        }
184
508
    }
185
}
186

            
187
#[derive(Debug)]
188
pub struct CliCommodityList;
189

            
190
impl CliCommand for CliCommodityList {
191
508
    fn node() -> CommandNode {
192
508
        CommandNode {
193
508
            name: "list".to_string(),
194
508
            is_leaf: true,
195
508
            comment: "List all commodities".to_string(),
196
508
            subcommands: vec![],
197
508
            arguments: vec![],
198
508
        }
199
508
    }
200
}
201

            
202
#[derive(Debug)]
203
pub struct CliCommodityCompletion;
204

            
205
impl CliRunnable for CliCommodityCompletion {
206
    fn run<'a>(
207
        &'a self,
208
        args: &'a HashMap<&str, &Argument>,
209
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
210
        use server::command::commodity::ListCommodities;
211
        Box::pin(async move {
212
            let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
213
                *user_id
214
            } else {
215
                return Err(CommandError::Execution(CmdError::Args(
216
                    "user_id is required".to_string(),
217
                )));
218
            };
219

            
220
            Ok(ListCommodities::new().user_id(user_id).run().await?)
221
        })
222
    }
223
}
224

            
225
#[derive(Debug)]
226
pub struct CliAccountCreate;
227

            
228
impl CliCommand for CliAccountCreate {
229
508
    fn node() -> CommandNode {
230
508
        CommandNode {
231
508
            name: "create".to_string(),
232
508
            is_leaf: true,
233
508
            comment: "Create new account".to_string(),
234
508
            subcommands: vec![],
235
508
            arguments: vec![
236
508
                ArgumentNode {
237
508
                    name: "name".to_string(),
238
508
                    comment: "Name of the account".to_string(),
239
508
                    completions: None,
240
508
                },
241
508
                ArgumentNode {
242
508
                    name: "parent".to_string(),
243
508
                    comment: "Optional parent account".to_string(),
244
508
                    completions: None,
245
508
                },
246
508
            ],
247
508
        }
248
508
    }
249
}
250

            
251
#[derive(Debug)]
252
pub struct CliAccountList;
253

            
254
impl CliCommand for CliAccountList {
255
508
    fn node() -> CommandNode {
256
508
        CommandNode {
257
508
            name: "list".to_string(),
258
508
            is_leaf: true,
259
508
            comment: "List all accounts".to_string(),
260
508
            subcommands: vec![],
261
508
            arguments: vec![],
262
508
        }
263
508
    }
264
}
265

            
266
#[derive(Debug)]
267
pub struct CliAccountCompletion;
268

            
269
impl CliRunnable for CliAccountCompletion {
270
    fn run<'a>(
271
        &'a self,
272
        args: &'a HashMap<&str, &Argument>,
273
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
274
        use server::command::account::ListAccounts;
275
        Box::pin(async move {
276
            let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
277
                *user_id
278
            } else {
279
                return Err(CommandError::Execution(CmdError::Args(
280
                    "user_id is required".to_string(),
281
                )));
282
            };
283

            
284
            Ok(ListAccounts::new().user_id(user_id).run().await?)
285
        })
286
    }
287
}
288

            
289
#[derive(Debug)]
290
pub struct CliTransactionCreate;
291

            
292
impl CliCommand for CliTransactionCreate {
293
508
    fn node() -> CommandNode {
294
508
        CommandNode {
295
508
            name: "create".to_string(),
296
508
            is_leaf: true,
297
508
            comment: "Create new transaction".to_string(),
298
508
            subcommands: vec![],
299
508
            arguments: vec![
300
508
                ArgumentNode {
301
508
                    name: "from".to_string(),
302
508
                    comment: "Source account".to_string(),
303
508
                    completions: Some(Box::new(CliAccountCompletion)),
304
508
                },
305
508
                ArgumentNode {
306
508
                    name: "to".to_string(),
307
508
                    comment: "Destination account".to_string(),
308
508
                    completions: Some(Box::new(CliAccountCompletion)),
309
508
                },
310
508
                ArgumentNode {
311
508
                    name: "from_currency".to_string(),
312
508
                    comment: "Currency for the source transaction".to_string(),
313
508
                    completions: Some(Box::new(CliCommodityCompletion)),
314
508
                },
315
508
                ArgumentNode {
316
508
                    name: "to_currency".to_string(),
317
508
                    comment: "Currency for the destination transaction".to_string(),
318
508
                    completions: Some(Box::new(CliCommodityCompletion)),
319
508
                },
320
508
                ArgumentNode {
321
508
                    name: "value".to_string(),
322
508
                    comment: "Transaction amount (from account)".to_string(),
323
508
                    completions: None,
324
508
                },
325
508
                ArgumentNode {
326
508
                    name: "to_amount".to_string(),
327
508
                    comment: "Transaction amount (to account, required when currencies differ)"
328
508
                        .to_string(),
329
508
                    completions: None,
330
508
                },
331
508
                ArgumentNode {
332
508
                    name: "note".to_string(),
333
508
                    comment: "Text memo for transaction".to_string(),
334
508
                    completions: None,
335
508
                },
336
508
            ],
337
508
        }
338
508
    }
339
}
340

            
341
#[derive(Debug)]
342
pub struct CliTransactionList;
343

            
344
impl CliCommand for CliTransactionList {
345
508
    fn node() -> CommandNode {
346
508
        CommandNode {
347
508
            name: "list".to_string(),
348
508
            is_leaf: true,
349
508
            comment: "List all transactions".to_string(),
350
508
            subcommands: vec![],
351
508
            arguments: vec![ArgumentNode {
352
508
                name: "account".to_string(),
353
508
                comment: "Optional account to filter by".to_string(),
354
508
                completions: Some(Box::new(CliAccountCompletion)),
355
508
            }],
356
508
        }
357
508
    }
358
}
359

            
360
#[derive(Debug)]
361
pub struct CliAccountBalance;
362

            
363
impl CliCommand for CliAccountBalance {
364
508
    fn node() -> CommandNode {
365
508
        CommandNode {
366
508
            name: "balance".to_string(),
367
508
            is_leaf: true,
368
508
            comment: "Get the current balance and currency of an account".to_string(),
369
508
            subcommands: vec![],
370
508
            arguments: vec![ArgumentNode {
371
508
                name: "account".to_string(),
372
508
                comment: "Account ID to get balance for".to_string(),
373
508
                completions: Some(Box::new(CliAccountCompletion)),
374
508
            }],
375
508
        }
376
508
    }
377
}
378

            
379
fn require_string(
380
    args: &HashMap<&str, &Argument>,
381
    key: &str,
382
    what: &str,
383
) -> Result<String, CommandError> {
384
    let Some(Argument::String(v)) = args.get(key) else {
385
        return Err(CommandError::Argument(format!("{what} is required")));
386
    };
387
    Ok(v.clone())
388
}
389

            
390
fn require_data(
391
    args: &HashMap<&str, &Argument>,
392
    key: &str,
393
    what: &str,
394
) -> Result<Vec<u8>, CommandError> {
395
    let Some(Argument::Data(v)) = args.get(key) else {
396
        return Err(CommandError::Argument(format!("{what} is required")));
397
    };
398
    Ok(v.clone())
399
}
400

            
401
fn require_uuid(
402
    args: &HashMap<&str, &Argument>,
403
    key: &str,
404
    what: &str,
405
) -> Result<Uuid, CommandError> {
406
    let Some(Argument::Uuid(v)) = args.get(key) else {
407
        return Err(CommandError::Argument(format!("{what} is required")));
408
    };
409
    Ok(*v)
410
}
411

            
412
#[derive(Debug)]
413
pub struct CliSshKeyAdd;
414

            
415
impl CliRunnable for CliSshKeyAdd {
416
    fn run<'a>(
417
        &'a self,
418
        args: &'a HashMap<&str, &Argument>,
419
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
420
        Box::pin(async move {
421
            let user_id = require_uuid(args, "user_id", "user_id")?;
422
            let key_type = require_string(args, "key_type", "key_type")?;
423
            let key_blob = require_data(args, "key_blob", "key_blob")?;
424
            let fingerprint = require_string(args, "fingerprint", "fingerprint")?;
425
            let mut cmd = server::command::ssh_key::AddSshKey::new()
426
                .user_id(user_id)
427
                .key_type(key_type)
428
                .key_blob(key_blob)
429
                .fingerprint(fingerprint);
430
            if let Some(Argument::String(a)) = args.get("annotation") {
431
                cmd = cmd.annotation(a.clone());
432
            }
433
            Ok(cmd.run().await?)
434
        })
435
    }
436
}
437

            
438
impl CliCommand for CliSshKeyAdd {
439
508
    fn node() -> CommandNode {
440
508
        CommandNode {
441
508
            name: "add".to_string(),
442
508
            is_leaf: true,
443
508
            comment: "Register a user's SSH public key".to_string(),
444
508
            subcommands: vec![],
445
508
            arguments: vec![
446
508
                ArgumentNode {
447
508
                    name: "key_type".to_string(),
448
508
                    comment: "OpenSSH algorithm, e.g. `ssh-ed25519`".to_string(),
449
508
                    completions: None,
450
508
                },
451
508
                ArgumentNode {
452
508
                    name: "key_blob".to_string(),
453
508
                    comment: "Decoded public-key wire bytes".to_string(),
454
508
                    completions: None,
455
508
                },
456
508
                ArgumentNode {
457
508
                    name: "fingerprint".to_string(),
458
508
                    comment: "SHA-256 fingerprint as `SHA256:<base64>`".to_string(),
459
508
                    completions: None,
460
508
                },
461
508
                ArgumentNode {
462
508
                    name: "annotation".to_string(),
463
508
                    comment: "Optional user-supplied label".to_string(),
464
508
                    completions: None,
465
508
                },
466
508
            ],
467
508
        }
468
508
    }
469
}
470

            
471
#[derive(Debug)]
472
pub struct CliSshKeyRemove;
473

            
474
impl CliCommand for CliSshKeyRemove {
475
508
    fn node() -> CommandNode {
476
508
        CommandNode {
477
508
            name: "remove".to_string(),
478
508
            is_leaf: true,
479
508
            comment: "Remove an SSH key by fingerprint".to_string(),
480
508
            subcommands: vec![],
481
508
            arguments: vec![ArgumentNode {
482
508
                name: "fingerprint".to_string(),
483
508
                comment: "SHA-256 fingerprint (SHA256:…)".to_string(),
484
508
                completions: None,
485
508
            }],
486
508
        }
487
508
    }
488
}
489

            
490
#[cfg(test)]
491
mod tests {
492
    use super::*;
493

            
494
1
    fn block_on<F: Future>(f: F) -> F::Output {
495
1
        tokio::runtime::Builder::new_current_thread()
496
1
            .enable_all()
497
1
            .build()
498
1
            .expect("runtime")
499
1
            .block_on(f)
500
1
    }
501

            
502
    #[test]
503
1
    fn select_column_rejects_missing_table() {
504
1
        let field = Argument::String("foo".to_string());
505
1
        let mut args: HashMap<&str, &Argument> = HashMap::new();
506
1
        args.insert("field", &field);
507
1
        let err = block_on(CliSelectColumn.run(&args)).expect_err("missing table should error");
508
1
        assert!(matches!(err, CommandError::Argument(_)));
509
1
    }
510
}