nut_shell/tree/
completion.rs

1//! Tab completion for commands and paths.
2//!
3//! Provides smart completion with prefix matching and directory handling.
4//! Uses stub function pattern - module always exists, functions return empty when disabled.
5
6#![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/// Tab completion result with type-safe variants for different match outcomes.
16#[derive(Debug, Clone, PartialEq)]
17pub enum CompletionResult<const MAX_MATCHES: usize> {
18    /// No matches found for the input prefix
19    None,
20
21    /// Exactly one match found (auto-completable)
22    Single {
23        /// The completed name (with "/" appended for directories)
24        // TODO: Use C::MAX_INPUT when const generics stabilize
25        completion: heapless::String<128>,
26
27        /// True if the match is a directory
28        is_directory: bool,
29    },
30
31    /// Multiple matches found (show options to user)
32    Multiple {
33        /// Common prefix of all matches
34        // TODO: Use C::MAX_INPUT when const generics stabilize
35        common_prefix: heapless::String<128>,
36
37        /// All matching node names (for display)
38        // TODO: Consider using C::MAX_INPUT or a separate config constant when const generics stabilize
39        all_matches: heapless::Vec<heapless::String<64>, MAX_MATCHES>,
40    },
41}
42
43impl<const MAX_MATCHES: usize> CompletionResult<MAX_MATCHES> {
44    /// Create empty completion result.
45    pub fn empty() -> Self {
46        Self::None
47    }
48}
49
50// ============================================================================
51// Feature-enabled implementation
52// ============================================================================
53
54/// Suggest completions for partial input using prefix matching.
55/// Directories get "/" appended for single matches.
56#[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    // Find all matching nodes
63    let mut matches: heapless::Vec<(&str, bool), MAX_MATCHES> = heapless::Vec::new();
64
65    for child in dir.children.iter() {
66        // Check access control
67        let node_level = match child {
68            Node::Command(cmd) => cmd.access_level,
69            Node::Directory(d) => d.access_level,
70        };
71
72        // Filter by access level
73        if let Some(user) = current_user
74            && user.access_level < node_level
75        {
76            continue; // User lacks access, skip this node
77        }
78
79        let name = child.name();
80        let is_dir = child.is_directory();
81
82        // Check prefix match
83        if name.starts_with(input) {
84            matches
85                .push((name, is_dir))
86                .map_err(|_| CliError::BufferFull)?;
87        }
88    }
89
90    // No matches
91    if matches.is_empty() {
92        return Ok(CompletionResult::None);
93    }
94
95    // Single match - complete!
96    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        // Auto-append "/" for directories
104        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    // Multiple matches - find common prefix
115    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    // Collect all match names for display
123    // TODO: Consider using C::MAX_INPUT or a separate config constant when const generics stabilize
124    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/// Find longest common prefix among multiple matches.
140#[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    // Find shortest match length
149    let min_len = matches.iter().map(|(s, _)| s.len()).min().unwrap_or(0);
150
151    // Find common prefix length
152    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// ============================================================================
167// Feature-disabled stub implementation
168// ============================================================================
169
170/// Stub implementation when completion feature is disabled.
171#[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// ============================================================================
181// Tests
182// ============================================================================
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use crate::auth::AccessLevel;
188    use crate::tree::{CommandKind, CommandMeta, Directory, Node};
189
190    // Test access level
191    #[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    // Test fixtures
218    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                // Common prefix is "st" for "status", "start", "stop"
328                assert_eq!(common_prefix.as_str(), "st");
329                assert_eq!(all_matches.len(), 3);
330
331                // Check all matches present (verify each is in the result)
332                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                // Should match: status, start, stop, system, services
356                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                // Expected - no matches
371            }
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                // Empty input matches everything
418                assert_eq!(all_matches.len(), 6); // 4 commands + 2 directories
419            }
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                // Expected - no matches (case-sensitive)
432            }
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                // Expected - stub always returns empty
445            }
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        // Create guest user (no access to Admin commands)
456        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        // "r" should NOT match "reboot" (Admin only) for guest user
470        let result =
471            suggest_completions::<TestLevel, 16>(&TEST_DIR, "r", Some(&guest_user)).unwrap();
472
473        match result {
474            CompletionResult::None => {
475                // Expected - no access to reboot command
476            }
477            _ => panic!("Expected None variant"),
478        }
479
480        // Create admin user
481        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        // "r" should match "reboot" for admin user
495        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        // Test internal helper
510        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, ""); // No common prefix
521    }
522
523    #[test]
524    #[cfg(feature = "completion")]
525    fn test_max_matches_exceeded() {
526        // Create directory with more nodes than MAX_MATCHES
527        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        // Use MAX_MATCHES = 2, but we have 4 matching items
576        let result = suggest_completions::<TestLevel, 2>(&OVERFLOW_DIR, "a", None);
577
578        // Should return BufferFull error
579        assert!(matches!(result, Err(CliError::BufferFull)));
580    }
581
582    #[test]
583    #[cfg(feature = "completion")]
584    fn test_very_long_command_name() {
585        // Create a command with name > 128 characters
586        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        // Try to complete - should return BufferFull error
603        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        // Create a directory with name = 128 characters (so adding "/" would overflow)
612        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        // Try to complete - should return BufferFull error when trying to append "/"
625        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        // Create commands with names > 64 characters (for all_matches buffer)
634        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        // Multiple matches with long names should cause BufferFull when building all_matches
660        let result = suggest_completions::<TestLevel, 16>(&LONG_MATCH_DIR, "match", None);
661
662        assert!(matches!(result, Err(CliError::BufferFull)));
663    }
664}