1#![cfg_attr(not(feature = "completion"), allow(unused_variables))]
7
8use crate::auth::AccessLevel;
9use crate::error::CliError;
10use crate::tree::Directory;
11
12#[cfg(feature = "completion")]
13use crate::tree::Node;
14
15#[derive(Debug, Clone, PartialEq)]
17pub enum CompletionResult<const MAX_MATCHES: usize> {
18 None,
20
21 Single {
23 completion: heapless::String<128>,
26
27 is_directory: bool,
29 },
30
31 Multiple {
33 common_prefix: heapless::String<128>,
36
37 all_matches: heapless::Vec<heapless::String<64>, MAX_MATCHES>,
40 },
41}
42
43impl<const MAX_MATCHES: usize> CompletionResult<MAX_MATCHES> {
44 pub fn empty() -> Self {
46 Self::None
47 }
48}
49
50#[cfg(feature = "completion")]
57pub fn suggest_completions<L: AccessLevel, const MAX_MATCHES: usize>(
58 dir: &Directory<L>,
59 input: &str,
60 current_user: Option<&crate::auth::User<L>>,
61) -> Result<CompletionResult<MAX_MATCHES>, CliError> {
62 let mut matches: heapless::Vec<(&str, bool), MAX_MATCHES> = heapless::Vec::new();
64
65 for child in dir.children.iter() {
66 let node_level = match child {
68 Node::Command(cmd) => cmd.access_level,
69 Node::Directory(d) => d.access_level,
70 };
71
72 if let Some(user) = current_user
74 && user.access_level < node_level
75 {
76 continue; }
78
79 let name = child.name();
80 let is_dir = child.is_directory();
81
82 if name.starts_with(input) {
84 matches
85 .push((name, is_dir))
86 .map_err(|_| CliError::BufferFull)?;
87 }
88 }
89
90 if matches.is_empty() {
92 return Ok(CompletionResult::None);
93 }
94
95 if matches.len() == 1 {
97 let (name, is_dir) = matches[0];
98 let mut completion = heapless::String::new();
99 completion
100 .push_str(name)
101 .map_err(|_| CliError::BufferFull)?;
102
103 if is_dir {
105 completion.push('/').map_err(|_| CliError::BufferFull)?;
106 }
107
108 return Ok(CompletionResult::Single {
109 completion,
110 is_directory: is_dir,
111 });
112 }
113
114 let common_prefix_str = find_common_prefix(&matches);
116
117 let mut common_prefix = heapless::String::new();
118 common_prefix
119 .push_str(common_prefix_str)
120 .map_err(|_| CliError::BufferFull)?;
121
122 let mut all_matches: heapless::Vec<heapless::String<64>, MAX_MATCHES> = heapless::Vec::new();
125 for (name, _) in matches.iter() {
126 let mut match_str = heapless::String::new();
127 match_str.push_str(name).map_err(|_| CliError::BufferFull)?;
128 all_matches
129 .push(match_str)
130 .map_err(|_| CliError::BufferFull)?;
131 }
132
133 Ok(CompletionResult::Multiple {
134 common_prefix,
135 all_matches,
136 })
137}
138
139#[cfg(feature = "completion")]
141fn find_common_prefix<'a>(matches: &[(&'a str, bool)]) -> &'a str {
142 if matches.is_empty() {
143 return "";
144 }
145
146 let first = matches[0].0;
147
148 let min_len = matches.iter().map(|(s, _)| s.len()).min().unwrap_or(0);
150
151 let mut prefix_len = 0;
153 for i in 0..min_len {
154 let ch = first.as_bytes()[i];
155 let all_match = matches.iter().all(|(s, _)| s.as_bytes()[i] == ch);
156 if all_match {
157 prefix_len = i + 1;
158 } else {
159 break;
160 }
161 }
162
163 &first[..prefix_len]
164}
165
166#[cfg(not(feature = "completion"))]
172pub fn suggest_completions<L: AccessLevel, const MAX_MATCHES: usize>(
173 _dir: &Directory<L>,
174 _input: &str,
175 _current_user: Option<&crate::auth::User<L>>,
176) -> Result<CompletionResult<MAX_MATCHES>, CliError> {
177 Ok(CompletionResult::empty())
178}
179
180#[cfg(test)]
185mod tests {
186 use super::*;
187 use crate::auth::AccessLevel;
188 use crate::tree::{CommandKind, CommandMeta, Directory, Node};
189
190 #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
192 enum TestLevel {
193 Guest = 0,
194 User = 1,
195 Admin = 2,
196 }
197
198 impl AccessLevel for TestLevel {
199 fn from_str(s: &str) -> Option<Self> {
200 match s {
201 "Guest" => Some(Self::Guest),
202 "User" => Some(Self::User),
203 "Admin" => Some(Self::Admin),
204 _ => None,
205 }
206 }
207
208 fn as_str(&self) -> &'static str {
209 match self {
210 Self::Guest => "Guest",
211 Self::User => "User",
212 Self::Admin => "Admin",
213 }
214 }
215 }
216
217 const CMD_STATUS: CommandMeta<TestLevel> = CommandMeta {
219 id: "status",
220 name: "status",
221 description: "Show status",
222 access_level: TestLevel::User,
223 kind: CommandKind::Sync,
224 min_args: 0,
225 max_args: 0,
226 };
227
228 const CMD_START: CommandMeta<TestLevel> = CommandMeta {
229 id: "start",
230 name: "start",
231 description: "Start service",
232 access_level: TestLevel::User,
233 kind: CommandKind::Sync,
234 min_args: 0,
235 max_args: 1,
236 };
237
238 const CMD_STOP: CommandMeta<TestLevel> = CommandMeta {
239 id: "stop",
240 name: "stop",
241 description: "Stop service",
242 access_level: TestLevel::User,
243 kind: CommandKind::Sync,
244 min_args: 0,
245 max_args: 0,
246 };
247
248 const CMD_REBOOT: CommandMeta<TestLevel> = CommandMeta {
249 id: "reboot",
250 name: "reboot",
251 description: "Reboot system",
252 access_level: TestLevel::Admin,
253 kind: CommandKind::Sync,
254 min_args: 0,
255 max_args: 0,
256 };
257
258 const DIR_SYSTEM: Directory<TestLevel> = Directory {
259 name: "system",
260 children: &[],
261 access_level: TestLevel::User,
262 };
263
264 const DIR_SERVICES: Directory<TestLevel> = Directory {
265 name: "services",
266 children: &[],
267 access_level: TestLevel::User,
268 };
269
270 const TEST_DIR: Directory<TestLevel> = Directory {
271 name: "test",
272 children: &[
273 Node::Command(&CMD_STATUS),
274 Node::Command(&CMD_START),
275 Node::Command(&CMD_STOP),
276 Node::Command(&CMD_REBOOT),
277 Node::Directory(&DIR_SYSTEM),
278 Node::Directory(&DIR_SERVICES),
279 ],
280 access_level: TestLevel::Guest,
281 };
282
283 #[test]
284 #[cfg(feature = "completion")]
285 fn test_single_match_command() {
286 let result = suggest_completions::<TestLevel, 16>(&TEST_DIR, "reb", None).unwrap();
287
288 match result {
289 CompletionResult::Single {
290 completion,
291 is_directory,
292 } => {
293 assert_eq!(completion.as_str(), "reboot");
294 assert!(!is_directory);
295 }
296 _ => panic!("Expected Single variant"),
297 }
298 }
299
300 #[test]
301 #[cfg(feature = "completion")]
302 fn test_single_match_directory() {
303 let result = suggest_completions::<TestLevel, 16>(&TEST_DIR, "syst", None).unwrap();
304
305 match result {
306 CompletionResult::Single {
307 completion,
308 is_directory,
309 } => {
310 assert_eq!(completion.as_str(), "system/");
311 assert!(is_directory);
312 }
313 _ => panic!("Expected Single variant"),
314 }
315 }
316
317 #[test]
318 #[cfg(feature = "completion")]
319 fn test_multiple_matches_with_common_prefix() {
320 let result = suggest_completions::<TestLevel, 16>(&TEST_DIR, "st", None).unwrap();
321
322 match result {
323 CompletionResult::Multiple {
324 common_prefix,
325 all_matches,
326 } => {
327 assert_eq!(common_prefix.as_str(), "st");
329 assert_eq!(all_matches.len(), 3);
330
331 let match_names: [&str; 3] = ["status", "start", "stop"];
333 for expected in &match_names {
334 assert!(
335 all_matches.iter().any(|m| m.as_str() == *expected),
336 "Expected to find '{}' in matches",
337 expected
338 );
339 }
340 }
341 _ => panic!("Expected Multiple variant"),
342 }
343 }
344
345 #[test]
346 #[cfg(feature = "completion")]
347 fn test_multiple_matches_directories() {
348 let result = suggest_completions::<TestLevel, 16>(&TEST_DIR, "s", None).unwrap();
349
350 match result {
351 CompletionResult::Multiple {
352 common_prefix,
353 all_matches,
354 } => {
355 assert_eq!(common_prefix.as_str(), "s");
357 assert_eq!(all_matches.len(), 5);
358 }
359 _ => panic!("Expected Multiple variant"),
360 }
361 }
362
363 #[test]
364 #[cfg(feature = "completion")]
365 fn test_no_matches() {
366 let result = suggest_completions::<TestLevel, 16>(&TEST_DIR, "xyz", None).unwrap();
367
368 match result {
369 CompletionResult::None => {
370 }
372 _ => panic!("Expected None variant"),
373 }
374 }
375
376 #[test]
377 #[cfg(feature = "completion")]
378 fn test_exact_match_command() {
379 let result = suggest_completions::<TestLevel, 16>(&TEST_DIR, "status", None).unwrap();
380
381 match result {
382 CompletionResult::Single {
383 completion,
384 is_directory,
385 } => {
386 assert_eq!(completion.as_str(), "status");
387 assert!(!is_directory);
388 }
389 _ => panic!("Expected Single variant"),
390 }
391 }
392
393 #[test]
394 #[cfg(feature = "completion")]
395 fn test_exact_match_directory() {
396 let result = suggest_completions::<TestLevel, 16>(&TEST_DIR, "system", None).unwrap();
397
398 match result {
399 CompletionResult::Single {
400 completion,
401 is_directory,
402 } => {
403 assert_eq!(completion.as_str(), "system/");
404 assert!(is_directory);
405 }
406 _ => panic!("Expected Single variant"),
407 }
408 }
409
410 #[test]
411 #[cfg(feature = "completion")]
412 fn test_empty_input_matches_all() {
413 let result = suggest_completions::<TestLevel, 16>(&TEST_DIR, "", None).unwrap();
414
415 match result {
416 CompletionResult::Multiple { all_matches, .. } => {
417 assert_eq!(all_matches.len(), 6); }
420 _ => panic!("Expected Multiple variant"),
421 }
422 }
423
424 #[test]
425 #[cfg(feature = "completion")]
426 fn test_case_sensitive_matching() {
427 let result = suggest_completions::<TestLevel, 16>(&TEST_DIR, "ST", None).unwrap();
428
429 match result {
430 CompletionResult::None => {
431 }
433 _ => panic!("Expected None variant"),
434 }
435 }
436
437 #[test]
438 #[cfg(not(feature = "completion"))]
439 fn test_stub_returns_empty() {
440 let result = suggest_completions::<TestLevel, 16>(&TEST_DIR, "st", None).unwrap();
441
442 match result {
443 CompletionResult::None => {
444 }
446 _ => panic!("Expected None variant"),
447 }
448 }
449
450 #[test]
451 #[cfg(feature = "completion")]
452 fn test_access_control_filtering() {
453 use crate::auth::User;
454
455 let guest_user = User {
457 username: {
458 let mut s = heapless::String::new();
459 s.push_str("guest").unwrap();
460 s
461 },
462 access_level: TestLevel::Guest,
463 #[cfg(feature = "authentication")]
464 password_hash: [0u8; 32],
465 #[cfg(feature = "authentication")]
466 salt: [0u8; 16],
467 };
468
469 let result =
471 suggest_completions::<TestLevel, 16>(&TEST_DIR, "r", Some(&guest_user)).unwrap();
472
473 match result {
474 CompletionResult::None => {
475 }
477 _ => panic!("Expected None variant"),
478 }
479
480 let admin_user = User {
482 username: {
483 let mut s = heapless::String::new();
484 s.push_str("admin").unwrap();
485 s
486 },
487 access_level: TestLevel::Admin,
488 #[cfg(feature = "authentication")]
489 password_hash: [0u8; 32],
490 #[cfg(feature = "authentication")]
491 salt: [0u8; 16],
492 };
493
494 let result =
496 suggest_completions::<TestLevel, 16>(&TEST_DIR, "r", Some(&admin_user)).unwrap();
497
498 match result {
499 CompletionResult::Single { completion, .. } => {
500 assert_eq!(completion.as_str(), "reboot");
501 }
502 _ => panic!("Expected Single variant"),
503 }
504 }
505
506 #[test]
507 #[cfg(feature = "completion")]
508 fn test_common_prefix_calculation() {
509 let matches = [("start", false), ("status", false), ("stop", false)];
511 let prefix = find_common_prefix(&matches);
512 assert_eq!(prefix, "st");
513
514 let matches = [("network", false), ("netscan", false)];
515 let prefix = find_common_prefix(&matches);
516 assert_eq!(prefix, "net");
517
518 let matches = [("abc", false), ("xyz", false)];
519 let prefix = find_common_prefix(&matches);
520 assert_eq!(prefix, ""); }
522
523 #[test]
524 #[cfg(feature = "completion")]
525 fn test_max_matches_exceeded() {
526 const CMD1: CommandMeta<TestLevel> = CommandMeta {
528 id: "a1",
529 name: "a1",
530 description: "Command 1",
531 access_level: TestLevel::Guest,
532 kind: CommandKind::Sync,
533 min_args: 0,
534 max_args: 0,
535 };
536 const CMD2: CommandMeta<TestLevel> = CommandMeta {
537 id: "a2",
538 name: "a2",
539 description: "Command 2",
540 access_level: TestLevel::Guest,
541 kind: CommandKind::Sync,
542 min_args: 0,
543 max_args: 0,
544 };
545 const CMD3: CommandMeta<TestLevel> = CommandMeta {
546 id: "a3",
547 name: "a3",
548 description: "Command 3",
549 access_level: TestLevel::Guest,
550 kind: CommandKind::Sync,
551 min_args: 0,
552 max_args: 0,
553 };
554 const CMD4: CommandMeta<TestLevel> = CommandMeta {
555 id: "a4",
556 name: "a4",
557 description: "Command 4",
558 access_level: TestLevel::Guest,
559 kind: CommandKind::Sync,
560 min_args: 0,
561 max_args: 0,
562 };
563
564 const OVERFLOW_DIR: Directory<TestLevel> = Directory {
565 name: "overflow",
566 children: &[
567 Node::Command(&CMD1),
568 Node::Command(&CMD2),
569 Node::Command(&CMD3),
570 Node::Command(&CMD4),
571 ],
572 access_level: TestLevel::Guest,
573 };
574
575 let result = suggest_completions::<TestLevel, 2>(&OVERFLOW_DIR, "a", None);
577
578 assert!(matches!(result, Err(CliError::BufferFull)));
580 }
581
582 #[test]
583 #[cfg(feature = "completion")]
584 fn test_very_long_command_name() {
585 const LONG_CMD: CommandMeta<TestLevel> = CommandMeta {
587 id: "long",
588 name: "this_is_a_very_long_command_name_that_exceeds_the_maximum_buffer_size_of_128_characters_and_should_cause_a_buffer_overflow_error_when_completing",
589 description: "Long command",
590 access_level: TestLevel::Guest,
591 kind: CommandKind::Sync,
592 min_args: 0,
593 max_args: 0,
594 };
595
596 const LONG_DIR: Directory<TestLevel> = Directory {
597 name: "long",
598 children: &[Node::Command(&LONG_CMD)],
599 access_level: TestLevel::Guest,
600 };
601
602 let result = suggest_completions::<TestLevel, 16>(&LONG_DIR, "this", None);
604
605 assert!(matches!(result, Err(CliError::BufferFull)));
606 }
607
608 #[test]
609 #[cfg(feature = "completion")]
610 fn test_long_directory_name_with_slash() {
611 const LONG_DIR_CHILD: Directory<TestLevel> = Directory {
613 name: "this_is_exactly_one_hundred_twenty_eight_characters_long_directory_name_abcdefghijklmnopqrstuvwxyz_0123456789_more_padding_needed",
614 children: &[],
615 access_level: TestLevel::Guest,
616 };
617
618 const LONG_DIR: Directory<TestLevel> = Directory {
619 name: "parent",
620 children: &[Node::Directory(&LONG_DIR_CHILD)],
621 access_level: TestLevel::Guest,
622 };
623
624 let result = suggest_completions::<TestLevel, 16>(&LONG_DIR, "this", None);
626
627 assert!(matches!(result, Err(CliError::BufferFull)));
628 }
629
630 #[test]
631 #[cfg(feature = "completion")]
632 fn test_match_name_exceeds_64_chars() {
633 const LONG1: CommandMeta<TestLevel> = CommandMeta {
635 id: "m1",
636 name: "match_name_that_is_longer_than_sixty_four_characters_abcdefghijklm",
637 description: "Long 1",
638 access_level: TestLevel::Guest,
639 kind: CommandKind::Sync,
640 min_args: 0,
641 max_args: 0,
642 };
643 const LONG2: CommandMeta<TestLevel> = CommandMeta {
644 id: "m2",
645 name: "match_name_that_is_longer_than_sixty_four_characters_nopqrstuvwxyz",
646 description: "Long 2",
647 access_level: TestLevel::Guest,
648 kind: CommandKind::Sync,
649 min_args: 0,
650 max_args: 0,
651 };
652
653 const LONG_MATCH_DIR: Directory<TestLevel> = Directory {
654 name: "longmatch",
655 children: &[Node::Command(&LONG1), Node::Command(&LONG2)],
656 access_level: TestLevel::Guest,
657 };
658
659 let result = suggest_completions::<TestLevel, 16>(&LONG_MATCH_DIR, "match", None);
661
662 assert!(matches!(result, Err(CliError::BufferFull)));
663 }
664}