nut_shell/shell/
history.rs

1//! Command history with up/down arrow navigation.
2//!
3//! Uses stub type pattern - struct always exists, but behavior is feature-gated.
4
5#![cfg_attr(not(feature = "history"), allow(unused_variables))]
6
7#[cfg(not(feature = "history"))]
8use core::marker::PhantomData;
9
10/// Command history storage (ring buffer when enabled, zero-size stub when disabled).
11#[derive(Debug)]
12pub struct CommandHistory<const N: usize, const INPUT_SIZE: usize> {
13    #[cfg(feature = "history")]
14    buffer: heapless::Vec<heapless::String<INPUT_SIZE>, N>,
15
16    #[cfg(feature = "history")]
17    position: Option<usize>,
18
19    #[cfg(not(feature = "history"))]
20    _phantom: PhantomData<[u8; INPUT_SIZE]>,
21}
22
23impl<const N: usize, const INPUT_SIZE: usize> CommandHistory<N, INPUT_SIZE> {
24    /// Create new command history.
25    #[cfg(feature = "history")]
26    pub fn new() -> Self {
27        Self {
28            buffer: heapless::Vec::new(),
29            position: None,
30        }
31    }
32
33    /// Create new command history (stub version).
34    #[cfg(not(feature = "history"))]
35    pub fn new() -> Self {
36        Self {
37            _phantom: PhantomData,
38        }
39    }
40
41    /// Add command to history.
42    #[cfg(feature = "history")]
43    pub fn add(&mut self, cmd: &str) {
44        // Don't add empty commands or duplicates
45        if cmd.is_empty() {
46            return;
47        }
48
49        // Don't add if same as most recent
50        if let Some(last) = self.buffer.last()
51            && last.as_str() == cmd
52        {
53            return;
54        }
55
56        let mut entry = heapless::String::new();
57        if entry.push_str(cmd).is_ok() {
58            // Ring buffer behavior - remove oldest if full
59            if self.buffer.is_full() {
60                self.buffer.remove(0);
61            }
62            let _ = self.buffer.push(entry);
63        }
64
65        // Reset position
66        self.position = None;
67    }
68
69    /// Add command to history (stub version - no-op).
70    #[cfg(not(feature = "history"))]
71    pub fn add(&mut self, _cmd: &str) {
72        // No-op
73    }
74
75    /// Navigate to previous command (up arrow).
76    #[cfg(feature = "history")]
77    pub fn previous_command(&mut self) -> Option<heapless::String<INPUT_SIZE>> {
78        if self.buffer.is_empty() {
79            return None;
80        }
81
82        let pos = match self.position {
83            None => self.buffer.len() - 1,
84            Some(0) => 0, // Already at oldest
85            Some(p) => p - 1,
86        };
87
88        self.position = Some(pos);
89        self.buffer.get(pos).cloned()
90    }
91
92    /// Navigate to previous command (stub version - returns None).
93    #[cfg(not(feature = "history"))]
94    pub fn previous_command(&mut self) -> Option<heapless::String<INPUT_SIZE>> {
95        None
96    }
97
98    /// Navigate to next command (down arrow).
99    #[cfg(feature = "history")]
100    pub fn next_command(&mut self) -> Option<heapless::String<INPUT_SIZE>> {
101        match self.position {
102            None => None, // Not navigating
103            Some(p) if p >= self.buffer.len() - 1 => {
104                // At newest - go to empty
105                self.position = None;
106                Some(heapless::String::new()) // Return empty string to clear buffer
107            }
108            Some(p) => {
109                let pos = p + 1;
110                self.position = Some(pos);
111                self.buffer.get(pos).cloned()
112            }
113        }
114    }
115
116    /// Navigate to next command (stub version - returns None).
117    #[cfg(not(feature = "history"))]
118    pub fn next_command(&mut self) -> Option<heapless::String<INPUT_SIZE>> {
119        None
120    }
121
122    /// Reset navigation position.
123    #[cfg(feature = "history")]
124    pub fn reset_position(&mut self) {
125        self.position = None;
126    }
127
128    /// Reset navigation position (stub version - no-op).
129    #[cfg(not(feature = "history"))]
130    pub fn reset_position(&mut self) {
131        // No-op
132    }
133}
134
135impl<const N: usize, const INPUT_SIZE: usize> Default for CommandHistory<N, INPUT_SIZE> {
136    fn default() -> Self {
137        Self::new()
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    #[cfg(feature = "history")]
147    fn test_add_and_navigate() {
148        let mut history = CommandHistory::<5, 128>::new();
149
150        history.add("cmd1");
151        history.add("cmd2");
152        history.add("cmd3");
153
154        // Navigate backwards
155        assert_eq!(history.previous_command().unwrap().as_str(), "cmd3");
156        assert_eq!(history.previous_command().unwrap().as_str(), "cmd2");
157        assert_eq!(history.previous_command().unwrap().as_str(), "cmd1");
158
159        // At oldest - should stay
160        assert_eq!(history.previous_command().unwrap().as_str(), "cmd1");
161
162        // Navigate forward
163        assert_eq!(history.next_command().unwrap().as_str(), "cmd2");
164        assert_eq!(history.next_command().unwrap().as_str(), "cmd3");
165
166        // At newest - should return empty string (to clear buffer)
167        assert_eq!(history.next_command().unwrap().as_str(), "");
168    }
169
170    #[test]
171    #[cfg(not(feature = "history"))]
172    fn test_stub_behavior() {
173        let mut history = CommandHistory::<5, 128>::new();
174
175        history.add("cmd1");
176        assert!(history.previous_command().is_none());
177        assert!(history.next_command().is_none());
178    }
179
180    #[test]
181    #[cfg(feature = "history")]
182    fn test_ring_buffer_behavior() {
183        let mut history = CommandHistory::<3, 128>::new();
184
185        // Fill buffer to capacity
186        history.add("cmd1");
187        history.add("cmd2");
188        history.add("cmd3");
189
190        // Add one more - should remove oldest (cmd1)
191        history.add("cmd4");
192
193        // Navigate to oldest (should be cmd2 now)
194        assert_eq!(history.previous_command().unwrap().as_str(), "cmd4");
195        assert_eq!(history.previous_command().unwrap().as_str(), "cmd3");
196        assert_eq!(history.previous_command().unwrap().as_str(), "cmd2");
197
198        // cmd1 should be gone
199        assert_eq!(history.previous_command().unwrap().as_str(), "cmd2"); // Stay at oldest
200    }
201
202    #[test]
203    #[cfg(feature = "history")]
204    fn test_empty_commands_ignored() {
205        let mut history = CommandHistory::<5, 128>::new();
206
207        history.add("");
208        history.add("cmd1");
209        history.add("");
210        history.add("cmd2");
211
212        // Only cmd1 and cmd2 should be in history
213        assert_eq!(history.previous_command().unwrap().as_str(), "cmd2");
214        assert_eq!(history.previous_command().unwrap().as_str(), "cmd1");
215        assert_eq!(history.previous_command().unwrap().as_str(), "cmd1"); // At oldest
216    }
217
218    #[test]
219    #[cfg(feature = "history")]
220    fn test_duplicate_commands_ignored() {
221        let mut history = CommandHistory::<5, 128>::new();
222
223        history.add("cmd1");
224        history.add("cmd1"); // Duplicate - should be ignored
225        history.add("cmd2");
226        history.add("cmd2"); // Duplicate - should be ignored
227        history.add("cmd1"); // Different from last - should be added
228
229        // Should have: cmd1, cmd2, cmd1
230        assert_eq!(history.previous_command().unwrap().as_str(), "cmd1");
231        assert_eq!(history.previous_command().unwrap().as_str(), "cmd2");
232        assert_eq!(history.previous_command().unwrap().as_str(), "cmd1");
233    }
234
235    #[test]
236    #[cfg(feature = "history")]
237    fn test_navigation_without_adding() {
238        let mut history = CommandHistory::<5, 128>::new();
239
240        // Try to navigate when empty
241        assert!(history.previous_command().is_none());
242        assert!(history.next_command().is_none());
243    }
244
245    #[test]
246    #[cfg(feature = "history")]
247    fn test_reset_position() {
248        let mut history = CommandHistory::<5, 128>::new();
249
250        history.add("cmd1");
251        history.add("cmd2");
252        history.add("cmd3");
253
254        // Navigate backwards
255        history.previous_command();
256        history.previous_command();
257
258        // Reset position
259        history.reset_position();
260
261        // Next previous should return most recent
262        assert_eq!(history.previous_command().unwrap().as_str(), "cmd3");
263    }
264
265    #[test]
266    #[cfg(feature = "history")]
267    fn test_position_resets_on_add() {
268        let mut history = CommandHistory::<5, 128>::new();
269
270        history.add("cmd1");
271        history.add("cmd2");
272
273        // Navigate backwards
274        history.previous_command();
275
276        // Add new command - position should reset
277        history.add("cmd3");
278
279        // Next previous should return most recent
280        assert_eq!(history.previous_command().unwrap().as_str(), "cmd3");
281    }
282
283    #[test]
284    #[cfg(feature = "history")]
285    fn test_default() {
286        let history = CommandHistory::<5, 128>::default();
287        let mut history2 = history;
288        assert!(history2.previous_command().is_none());
289    }
290
291    #[test]
292    #[cfg(not(feature = "history"))]
293    fn test_stub_reset_position() {
294        let mut history = CommandHistory::<5, 128>::new();
295        history.reset_position(); // Should not panic
296    }
297}