1use anyhow::{Context, Result};
6use rustyline::completion::{Completer, Pair};
7use rustyline::error::ReadlineError;
8use rustyline::highlight::Highlighter;
9use rustyline::hint::Hinter;
10use rustyline::validate::Validator;
11use rustyline::{Editor, Helper};
12use std::collections::HashMap;
13use std::path::PathBuf;
14use tracing::{debug, info};
15
16use crate::output::{error, print_header, success};
17
18#[allow(clippy::needless_range_loop)]
20fn levenshtein_distance(s1: &str, s2: &str) -> usize {
21 let len1 = s1.len();
22 let len2 = s2.len();
23 let mut matrix = vec![vec![0; len2 + 1]; len1 + 1];
24
25 for i in 0..=len1 {
26 matrix[i][0] = i;
27 }
28 for j in 0..=len2 {
29 matrix[0][j] = j;
30 }
31
32 for (i, c1) in s1.chars().enumerate() {
33 for (j, c2) in s2.chars().enumerate() {
34 let cost = if c1 == c2 { 0 } else { 1 };
35 matrix[i + 1][j + 1] = std::cmp::min(
36 std::cmp::min(matrix[i][j + 1] + 1, matrix[i + 1][j] + 1),
37 matrix[i][j] + cost,
38 );
39 }
40 }
41
42 matrix[len1][len2]
43}
44
45#[derive(Debug, Clone)]
47struct CommandCompleter {
48 commands: Vec<String>,
49}
50
51impl CommandCompleter {
52 fn new() -> Self {
53 Self {
54 commands: vec![
55 "help".to_string(),
57 "?".to_string(),
58 "h".to_string(),
59 "exit".to_string(),
60 "quit".to_string(),
61 "q".to_string(),
62 "bye".to_string(),
63 "clear".to_string(),
64 "cls".to_string(),
65 "clean".to_string(),
66 "version".to_string(),
67 "pwd".to_string(),
68 "info".to_string(),
69 "add".to_string(),
71 "get".to_string(),
72 "cat".to_string(),
73 "ls".to_string(),
74 "id".to_string(),
76 "peers".to_string(),
77 "peer".to_string(),
78 "connect".to_string(),
79 "disconnect".to_string(),
80 "stats".to_string(),
82 "stat".to_string(),
83 "semantic".to_string(),
85 "search".to_string(),
86 "find".to_string(),
87 "logic".to_string(),
88 "infer".to_string(),
89 "query".to_string(),
90 "tensor".to_string(),
91 "model".to_string(),
92 "gradient".to_string(),
93 "pin".to_string(),
95 "unpin".to_string(),
96 "alias".to_string(),
98 "unalias".to_string(),
99 "ll".to_string(),
101 "list".to_string(),
102 "show".to_string(),
103 "view".to_string(),
104 "download".to_string(),
105 "upload".to_string(),
106 "put".to_string(),
107 "connections".to_string(),
108 "nodes".to_string(),
109 "whoami".to_string(),
110 "status".to_string(),
111 "statistics".to_string(),
112 "logout".to_string(),
113 "leave".to_string(),
114 ],
115 }
116 }
117}
118
119impl Completer for CommandCompleter {
120 type Candidate = Pair;
121
122 fn complete(
123 &self,
124 line: &str,
125 pos: usize,
126 _ctx: &rustyline::Context<'_>,
127 ) -> rustyline::Result<(usize, Vec<Pair>)> {
128 let start = line[..pos]
129 .rfind(char::is_whitespace)
130 .map(|i| i + 1)
131 .unwrap_or(0);
132
133 let prefix = &line[start..pos];
134
135 let matches: Vec<Pair> = self
136 .commands
137 .iter()
138 .filter(|cmd| cmd.starts_with(prefix))
139 .map(|cmd| Pair {
140 display: cmd.clone(),
141 replacement: cmd.clone(),
142 })
143 .collect();
144
145 Ok((start, matches))
146 }
147}
148
149impl Hinter for CommandCompleter {
150 type Hint = String;
151
152 fn hint(&self, line: &str, pos: usize, _ctx: &rustyline::Context<'_>) -> Option<String> {
153 if pos < line.len() {
154 return None;
155 }
156
157 let parts: Vec<&str> = line.split_whitespace().collect();
158 if parts.is_empty() {
159 return None;
160 }
161
162 match parts[0] {
164 "add" if parts.len() == 1 => Some(" <path>".to_string()),
165 "get" if parts.len() == 1 => Some(" <cid> [output]".to_string()),
166 "cat" if parts.len() == 1 => Some(" <cid>".to_string()),
167 "ls" if parts.len() == 1 => Some(" <cid>".to_string()),
168 "stats" if parts.len() == 1 => Some(" [storage|semantic|logic]".to_string()),
169 "semantic" if parts.len() == 1 => Some(" <search|stats> [args...]".to_string()),
170 "logic" if parts.len() == 1 => Some(" <infer|prove|kb-stats> [args...]".to_string()),
171 "search" | "find" if parts.len() == 1 => Some(" <query>".to_string()),
172 "infer" | "query" if parts.len() == 1 => Some(" <goal>".to_string()),
173 _ => {
174 let prefix = parts[0];
176 self.commands
177 .iter()
178 .find(|cmd| cmd.starts_with(prefix) && cmd.len() > prefix.len())
179 .map(|cmd| cmd[prefix.len()..].to_string())
180 }
181 }
182 }
183}
184
185impl Highlighter for CommandCompleter {}
186
187impl Validator for CommandCompleter {
188 fn validate(
189 &self,
190 ctx: &mut rustyline::validate::ValidationContext,
191 ) -> rustyline::Result<rustyline::validate::ValidationResult> {
192 let input = ctx.input();
193
194 if input.ends_with('\\') {
196 return Ok(rustyline::validate::ValidationResult::Incomplete);
197 }
198
199 let quote_count = input.chars().filter(|&c| c == '"').count();
201 if quote_count % 2 != 0 {
202 return Ok(rustyline::validate::ValidationResult::Incomplete);
203 }
204
205 let open_parens = input.chars().filter(|&c| c == '(').count();
207 let close_parens = input.chars().filter(|&c| c == ')').count();
208 if open_parens > close_parens {
209 return Ok(rustyline::validate::ValidationResult::Incomplete);
210 }
211
212 Ok(rustyline::validate::ValidationResult::Valid(None))
213 }
214}
215
216impl Helper for CommandCompleter {}
217
218#[derive(Debug, Clone)]
220pub struct ShellConfig {
221 pub data_dir: PathBuf,
223 pub history_file: PathBuf,
225 pub prompt: String,
227 pub aliases: HashMap<String, String>,
229}
230
231impl Default for ShellConfig {
232 fn default() -> Self {
233 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
234
235 let mut aliases = HashMap::new();
237
238 aliases.insert("ll".to_string(), "ls".to_string());
240 aliases.insert("list".to_string(), "ls".to_string());
241 aliases.insert("show".to_string(), "cat".to_string());
242 aliases.insert("view".to_string(), "cat".to_string());
243 aliases.insert("download".to_string(), "get".to_string());
244 aliases.insert("upload".to_string(), "add".to_string());
245 aliases.insert("put".to_string(), "add".to_string());
246
247 aliases.insert("connections".to_string(), "peers".to_string());
249 aliases.insert("nodes".to_string(), "peers".to_string());
250 aliases.insert("whoami".to_string(), "id".to_string());
251
252 aliases.insert("status".to_string(), "stats".to_string());
254 aliases.insert("statistics".to_string(), "stats".to_string());
255
256 aliases.insert("logout".to_string(), "exit".to_string());
258 aliases.insert("leave".to_string(), "exit".to_string());
259
260 Self {
261 data_dir: PathBuf::from(".ipfrs"),
262 history_file: home.join(".ipfrs_history"),
263 prompt: "ipfrs> ".to_string(),
264 aliases,
265 }
266 }
267}
268
269pub struct Shell {
271 config: ShellConfig,
272 editor: Editor<CommandCompleter, rustyline::history::DefaultHistory>,
273}
274
275impl Shell {
276 pub fn new(config: ShellConfig) -> Result<Self> {
278 let mut editor = Editor::new().context("Failed to create line editor")?;
279
280 editor.set_helper(Some(CommandCompleter::new()));
282
283 if config.history_file.exists() {
285 if let Err(e) = editor.load_history(&config.history_file) {
286 debug!("Failed to load history: {}", e);
287 }
288 }
289
290 Ok(Self { config, editor })
291 }
292
293 pub async fn run(&mut self) -> Result<()> {
295 print_header("IPFRS Interactive Shell");
296 println!("Type 'help' for available commands, 'exit' or Ctrl+D to quit");
297 println!("Use Tab for completion, Up/Down for history, Ctrl+R for search");
298 println!("Multi-line input: end line with \\ or leave quotes/parentheses unclosed\n");
299
300 loop {
301 match self.editor.readline(&self.config.prompt) {
302 Ok(line) => {
303 let line = line.trim();
304 if line.is_empty() {
305 continue;
306 }
307
308 let _ = self.editor.add_history_entry(line);
310
311 match self.execute_command(line).await {
313 Ok(should_continue) => {
314 if !should_continue {
315 break;
316 }
317 }
318 Err(e) => {
319 error(&format!("Error: {}", e));
320 }
321 }
322 }
323 Err(ReadlineError::Interrupted) => {
324 println!("^C");
325 continue;
326 }
327 Err(ReadlineError::Eof) => {
328 println!("Goodbye!");
329 break;
330 }
331 Err(err) => {
332 error(&format!("Error reading line: {}", err));
333 break;
334 }
335 }
336 }
337
338 if let Err(e) = self.editor.save_history(&self.config.history_file) {
340 debug!("Failed to save history: {}", e);
341 }
342
343 Ok(())
344 }
345
346 async fn execute_command(&mut self, line: &str) -> Result<bool> {
349 let parts: Vec<&str> = line.split_whitespace().collect();
350 if parts.is_empty() {
351 return Ok(true);
352 }
353
354 let command = if let Some(resolved) = self.config.aliases.get(parts[0]) {
356 resolved.as_str()
357 } else {
358 parts[0]
359 };
360
361 match command {
362 "help" | "?" | "h" => {
363 self.show_help();
364 Ok(true)
365 }
366 "exit" | "quit" | "q" | "bye" => {
367 println!("Goodbye!");
368 Ok(false)
369 }
370 "clear" | "cls" | "clean" => {
371 print!("\x1B[2J\x1B[1;1H");
372 Ok(true)
373 }
374 "version" => {
375 println!("IPFRS version {}", env!("CARGO_PKG_VERSION"));
376 Ok(true)
377 }
378 "pwd" => {
379 println!("{}", self.config.data_dir.display());
380 Ok(true)
381 }
382 "info" => {
383 self.show_info().await;
384 Ok(true)
385 }
386 "stats" | "stat" => {
387 self.show_stats(parts.get(1).copied()).await;
388 Ok(true)
389 }
390 "ls" => {
391 if parts.len() < 2 {
392 error("Usage: ls <cid>");
393 } else {
394 self.list_directory(parts[1]).await;
395 }
396 Ok(true)
397 }
398 "cat" => {
399 if parts.len() < 2 {
400 error("Usage: cat <cid>");
401 } else {
402 self.cat_file(parts[1]).await;
403 }
404 Ok(true)
405 }
406 "add" => {
407 if parts.len() < 2 {
408 error("Usage: add <path>");
409 } else {
410 self.add_file(parts[1]).await;
411 }
412 Ok(true)
413 }
414 "get" => {
415 if parts.len() < 2 {
416 error("Usage: get <cid> [output_path]");
417 } else {
418 let output = parts.get(2).copied();
419 self.get_file(parts[1], output).await;
420 }
421 Ok(true)
422 }
423 "peers" | "peer" => {
424 self.list_peers().await;
425 Ok(true)
426 }
427 "id" => {
428 self.show_id().await;
429 Ok(true)
430 }
431 "semantic" | "search" | "find" => {
432 if parts.len() < 2 {
433 error("Usage: semantic <search|stats> [args...] (or use: search/find <query>)");
434 } else {
435 self.semantic_command(&parts[1..]).await;
436 }
437 Ok(true)
438 }
439 "logic" | "infer" | "query" => {
440 if parts.len() < 2 {
441 error("Usage: logic <infer|prove|kb-stats> [args...] (or use: infer/query <goal>)");
442 } else {
443 self.logic_command(&parts[1..]).await;
444 }
445 Ok(true)
446 }
447 "alias" => {
448 if parts.len() < 2 {
449 self.list_aliases();
451 } else if parts.len() == 2 {
452 if let Some(resolved) = self.config.aliases.get(parts[1]) {
454 println!("'{}' is aliased to '{}'", parts[1], resolved);
455 } else {
456 error(&format!("No alias found for '{}'", parts[1]));
457 }
458 } else if parts.len() >= 3 {
459 let alias_name = parts[1].to_string();
461 let alias_command = parts[2..].join(" ");
462 self.config
463 .aliases
464 .insert(alias_name.clone(), alias_command.clone());
465 success(&format!(
466 "Alias '{}' -> '{}' added",
467 alias_name, alias_command
468 ));
469 }
470 Ok(true)
471 }
472 "unalias" => {
473 if parts.len() < 2 {
474 error("Usage: unalias <alias_name>");
475 } else if self.config.aliases.remove(parts[1]).is_some() {
476 success(&format!("Alias '{}' removed", parts[1]));
477 } else {
478 error(&format!("No alias found for '{}'", parts[1]));
479 }
480 Ok(true)
481 }
482 _ => {
483 let suggestion = self.suggest_command(parts[0]);
485 if let Some(suggested) = suggestion {
486 error(&format!(
487 "Unknown command: '{}'. Did you mean '{}'? Type 'help' for available commands.",
488 parts[0], suggested
489 ));
490 } else {
491 error(&format!(
492 "Unknown command: '{}'. Type 'help' for available commands.",
493 parts[0]
494 ));
495 }
496 Ok(true)
497 }
498 }
499 }
500
501 fn suggest_command(&self, input: &str) -> Option<String> {
503 let commands = vec![
504 "help", "exit", "quit", "clear", "version", "pwd", "info", "add", "get", "cat", "ls",
505 "peers", "id", "stats", "semantic", "search", "logic", "infer", "alias", "unalias",
506 ];
507
508 for cmd in &commands {
510 if cmd.starts_with(input) && cmd.len() > input.len() {
511 return Some(cmd.to_string());
512 }
513 }
514
515 for cmd in &commands {
517 if levenshtein_distance(input, cmd) == 1 {
518 return Some(cmd.to_string());
519 }
520 }
521
522 None
523 }
524
525 fn list_aliases(&self) {
527 if self.config.aliases.is_empty() {
528 println!("No aliases defined.");
529 return;
530 }
531
532 println!("\n{}", "=".repeat(60));
533 println!("Defined Aliases");
534 println!("{}\n", "=".repeat(60));
535
536 let mut aliases: Vec<_> = self.config.aliases.iter().collect();
537 aliases.sort_by(|a, b| a.0.cmp(b.0));
538
539 for (alias, command) in aliases {
540 println!(" {} -> {}", alias, command);
541 }
542 println!();
543 }
544
545 fn show_help(&self) {
547 println!("\n{}", "=".repeat(60));
548 println!("IPFRS Interactive Shell - Available Commands");
549 println!("{}\n", "=".repeat(60));
550
551 println!("General:");
552 println!(" help, ?, h Show this help message");
553 println!(" exit, quit, q, bye Exit the shell");
554 println!(" clear, cls, clean Clear the screen");
555 println!(" version Show version information");
556 println!(" info Show node information");
557 println!(" pwd Show current data directory");
558
559 println!("\nFile Operations:");
560 println!(" add <path> Add a file to IPFRS");
561 println!(" get <cid> [output] Get a file from IPFRS");
562 println!(" cat <cid> Display file contents");
563 println!(" ls <cid> List directory contents");
564
565 println!("\nNetwork:");
566 println!(" id Show peer ID and addresses");
567 println!(" peers, peer List connected peers");
568
569 println!("\nStatistics:");
570 println!(" stats, stat Show all statistics");
571 println!(" stats storage Show storage statistics");
572 println!(" stats semantic Show semantic search statistics");
573 println!(" stats logic Show logic programming statistics");
574
575 println!("\nSemantic Search:");
576 println!(" semantic search <query> Search similar content");
577 println!(" search <query> Alias for semantic search");
578 println!(" find <query> Alias for semantic search");
579 println!(" semantic stats Show semantic statistics");
580
581 println!("\nLogic Programming:");
582 println!(" logic infer <goal> Run inference query");
583 println!(" infer <goal> Alias for logic infer");
584 println!(" query <goal> Alias for logic infer");
585 println!(" logic prove <goal> Generate proof");
586 println!(" logic kb-stats Show knowledge base statistics");
587
588 println!("\nAlias Management:");
589 println!(" alias List all aliases");
590 println!(" alias <name> Show specific alias");
591 println!(" alias <name> <cmd> Create new alias");
592 println!(" unalias <name> Remove an alias");
593
594 println!("\nCommon Aliases:");
595 println!(" ll, list → ls download → get");
596 println!(" show, view → cat upload, put → add");
597 println!(" whoami → id status → stats");
598 println!(" connections, nodes → peers");
599
600 println!("\nTips:");
601 println!(" • Use Tab for command completion");
602 println!(" • Use Up/Down arrows for command history");
603 println!(" • Create custom aliases with 'alias' command");
604 println!(" • Typos will suggest similar commands");
605
606 println!("\n{}", "=".repeat(60));
607 }
608
609 #[allow(dead_code)]
611 async fn show_info(&self) {
612 println!("\nIPFRS Node Information:");
613 println!(" Data directory: {}", self.config.data_dir.display());
614 println!(" Status: Running");
615 success("Node is operational");
616 }
617
618 #[allow(dead_code)]
620 async fn show_stats(&self, category: Option<&str>) {
621 match category {
622 None => {
623 println!("\nNode Statistics:");
624 println!(" Storage: Available");
625 println!(" Semantic: Available");
626 println!(" Logic: Available");
627 info!("Use 'stats <category>' for detailed statistics");
628 }
629 Some("storage") => {
630 println!("\nStorage Statistics:");
631 println!(" Blocks: N/A (connect to daemon)");
632 }
633 Some("semantic") => {
634 println!("\nSemantic Statistics:");
635 println!(" Indexed vectors: N/A (connect to daemon)");
636 }
637 Some("logic") => {
638 println!("\nLogic Statistics:");
639 println!(" Facts: N/A (connect to daemon)");
640 println!(" Rules: N/A (connect to daemon)");
641 }
642 Some(cat) => {
643 error(&format!(
644 "Unknown category: '{}'. Use storage, semantic, or logic.",
645 cat
646 ));
647 }
648 }
649 }
650
651 #[allow(dead_code)]
653 async fn list_directory(&self, _cid: &str) {
654 info!("Directory listing not yet implemented in shell");
655 println!("Use 'ipfrs ls <cid>' from the command line");
656 }
657
658 #[allow(dead_code)]
660 async fn cat_file(&self, _cid: &str) {
661 info!("Cat command not yet implemented in shell");
662 println!("Use 'ipfrs cat <cid>' from the command line");
663 }
664
665 #[allow(dead_code)]
667 async fn add_file(&self, _path: &str) {
668 info!("Add command not yet implemented in shell");
669 println!("Use 'ipfrs add <path>' from the command line");
670 }
671
672 #[allow(dead_code)]
674 async fn get_file(&self, _cid: &str, _output: Option<&str>) {
675 info!("Get command not yet implemented in shell");
676 println!("Use 'ipfrs get <cid>' from the command line");
677 }
678
679 #[allow(dead_code)]
681 async fn list_peers(&self) {
682 info!("Peers command not yet implemented in shell");
683 println!("Use 'ipfrs swarm peers' from the command line");
684 }
685
686 #[allow(dead_code)]
688 async fn show_id(&self) {
689 info!("ID command not yet implemented in shell");
690 println!("Use 'ipfrs id' from the command line");
691 }
692
693 #[allow(dead_code)]
695 async fn semantic_command(&self, _args: &[&str]) {
696 info!("Semantic commands not yet implemented in shell");
697 println!("Use 'ipfrs semantic <command>' from the command line");
698 }
699
700 #[allow(dead_code)]
702 async fn logic_command(&self, _args: &[&str]) {
703 info!("Logic commands not yet implemented in shell");
704 println!("Use 'ipfrs logic <command>' from the command line");
705 }
706}
707
708#[cfg(test)]
709mod tests {
710 use super::*;
711
712 #[test]
713 fn test_shell_config_default() {
714 let config = ShellConfig::default();
715 assert_eq!(config.prompt, "ipfrs> ");
716 assert_eq!(config.data_dir, PathBuf::from(".ipfrs"));
717 }
718
719 #[test]
720 fn test_shell_creation() {
721 let config = ShellConfig::default();
722 let result = Shell::new(config);
723 assert!(result.is_ok());
724 }
725
726 #[test]
727 fn test_command_completer() {
728 let completer = CommandCompleter::new();
729 assert!(!completer.commands.is_empty());
730 assert!(completer.commands.contains(&"help".to_string()));
731 assert!(completer.commands.contains(&"exit".to_string()));
732 assert!(completer.commands.contains(&"add".to_string()));
733 }
734
735 #[test]
736 fn test_command_completer_aliases() {
737 let completer = CommandCompleter::new();
738 assert!(completer.commands.contains(&"?".to_string()));
740 assert!(completer.commands.contains(&"q".to_string()));
741 assert!(completer.commands.contains(&"h".to_string()));
742 assert!(completer.commands.contains(&"search".to_string()));
743 assert!(completer.commands.contains(&"find".to_string()));
744 }
745
746 #[test]
747 fn test_command_hint_logic() {
748 let completer = CommandCompleter::new();
749
750 let commands = &completer.commands;
754
755 assert!(commands.contains(&"add".to_string()));
757 assert!(commands.contains(&"get".to_string()));
758 assert!(commands.contains(&"cat".to_string()));
759 assert!(commands.contains(&"ls".to_string()));
760 assert!(commands.contains(&"stats".to_string()));
761 assert!(commands.contains(&"semantic".to_string()));
762 assert!(commands.contains(&"logic".to_string()));
763 }
764
765 #[test]
766 fn test_multiline_validation_logic() {
767 assert!("test \\".ends_with('\\'));
772 assert!(!"test".ends_with('\\'));
773
774 let quote_count_odd = "add \"file".chars().filter(|&c| c == '"').count();
776 let quote_count_even = "add \"file\"".chars().filter(|&c| c == '"').count();
777 assert_eq!(quote_count_odd % 2, 1); assert_eq!(quote_count_even % 2, 0); let input1 = "logic (foo";
782 let open1 = input1.chars().filter(|&c| c == '(').count();
783 let close1 = input1.chars().filter(|&c| c == ')').count();
784 assert!(open1 > close1); let input2 = "logic (foo)";
787 let open2 = input2.chars().filter(|&c| c == '(').count();
788 let close2 = input2.chars().filter(|&c| c == ')').count();
789 assert_eq!(open2, close2); }
791
792 #[test]
793 fn test_levenshtein_distance() {
794 assert_eq!(levenshtein_distance("", ""), 0);
795 assert_eq!(levenshtein_distance("cat", "cat"), 0);
796 assert_eq!(levenshtein_distance("cat", "bat"), 1);
797 assert_eq!(levenshtein_distance("cat", "ca"), 1);
798 assert_eq!(levenshtein_distance("cat", "cats"), 1);
799 assert_eq!(levenshtein_distance("help", "halp"), 1);
800 assert_eq!(levenshtein_distance("add", "dad"), 2); assert_eq!(levenshtein_distance("exit", "exot"), 1); assert_eq!(levenshtein_distance("kitten", "sitting"), 3);
803 }
804
805 #[test]
806 fn test_shell_config_aliases() {
807 let config = ShellConfig::default();
808
809 assert!(config.aliases.contains_key("ll"));
811 assert_eq!(config.aliases.get("ll").unwrap(), "ls");
812
813 assert!(config.aliases.contains_key("whoami"));
814 assert_eq!(config.aliases.get("whoami").unwrap(), "id");
815
816 assert!(config.aliases.contains_key("upload"));
817 assert_eq!(config.aliases.get("upload").unwrap(), "add");
818
819 assert!(config.aliases.contains_key("download"));
820 assert_eq!(config.aliases.get("download").unwrap(), "get");
821 }
822
823 #[test]
824 fn test_command_completer_includes_aliases() {
825 let completer = CommandCompleter::new();
826
827 assert!(completer.commands.contains(&"ll".to_string()));
829 assert!(completer.commands.contains(&"whoami".to_string()));
830 assert!(completer.commands.contains(&"alias".to_string()));
831 assert!(completer.commands.contains(&"unalias".to_string()));
832 assert!(completer.commands.contains(&"upload".to_string()));
833 assert!(completer.commands.contains(&"download".to_string()));
834 }
835}