Skip to main content

limit_cli/tui/input/
history.rs

1//! Input history management for TUI
2//!
3//! Provides persistent history of user inputs with navigation support.
4
5use bincode::{deserialize, serialize};
6use std::fs;
7use std::path::PathBuf;
8
9/// Maximum number of entries to keep in history
10const MAX_HISTORY_SIZE: usize = 500;
11
12/// Input history with navigation support
13#[derive(Debug, Clone)]
14pub struct InputHistory {
15    /// History entries (index 0 = most recent)
16    entries: Vec<String>,
17    /// Maximum number of entries to keep
18    max_size: usize,
19    /// Current navigation position (None = new input, Some(0) = most recent)
20    current_index: Option<usize>,
21    /// Saved draft before navigation started
22    saved_draft: Option<String>,
23}
24
25impl InputHistory {
26    /// Create a new empty history
27    pub fn new() -> Self {
28        Self {
29            entries: Vec::with_capacity(100),
30            max_size: MAX_HISTORY_SIZE,
31            current_index: None,
32            saved_draft: None,
33        }
34    }
35
36    /// Create a new history with custom max size
37    pub fn with_max_size(max_size: usize) -> Self {
38        Self {
39            entries: Vec::with_capacity(100),
40            max_size,
41            current_index: None,
42            saved_draft: None,
43        }
44    }
45
46    /// Add a new entry to history
47    ///
48    /// - Ignores empty strings
49    /// - Ignores duplicates of the most recent entry
50    /// - Truncates to max_size if needed
51    pub fn add(&mut self, text: &str) {
52        let text = text.trim();
53
54        // Skip empty entries
55        if text.is_empty() {
56            return;
57        }
58
59        // Skip if same as most recent
60        if self.entries.first().map(|s| s.as_str()) == Some(text) {
61            return;
62        }
63
64        // Add to front
65        self.entries.insert(0, text.to_string());
66
67        // Truncate if needed
68        if self.entries.len() > self.max_size {
69            self.entries.truncate(self.max_size);
70        }
71
72        // Reset navigation
73        self.current_index = None;
74        self.saved_draft = None;
75    }
76
77    /// Navigate to previous (older) entry
78    ///
79    /// Returns the entry at the new position, or None if at the oldest
80    /// Saves current draft before navigation starts
81    pub fn navigate_up(&mut self, current_draft: &str) -> Option<&str> {
82        if self.entries.is_empty() {
83            return None;
84        }
85
86        // Save draft before first navigation
87        if self.current_index.is_none() {
88            self.saved_draft = if current_draft.is_empty() {
89                None
90            } else {
91                Some(current_draft.to_string())
92            };
93        }
94
95        // Move to older entry
96        match self.current_index {
97            None => {
98                self.current_index = Some(0);
99                self.entries.first().map(|s| s.as_str())
100            }
101            Some(idx) if idx + 1 < self.entries.len() => {
102                self.current_index = Some(idx + 1);
103                self.entries.get(idx + 1).map(|s| s.as_str())
104            }
105            Some(_) => {
106                // Already at oldest, return current
107                self.current()
108            }
109        }
110    }
111
112    /// Navigate to next (newer) entry
113    ///
114    /// Returns the entry at the new position, or restores draft if at newest
115    pub fn navigate_down(&mut self) -> Option<&str> {
116        match self.current_index {
117            None => {
118                // Already at newest/new input, nothing to do
119                None
120            }
121            Some(0) => {
122                // At newest, restore draft or clear
123                self.current_index = None;
124                None // Signal to restore draft
125            }
126            Some(idx) => {
127                self.current_index = Some(idx - 1);
128                self.current()
129            }
130        }
131    }
132
133    /// Get current entry (if navigating)
134    pub fn current(&self) -> Option<&str> {
135        self.current_index
136            .and_then(|idx| self.entries.get(idx).map(|s| s.as_str()))
137    }
138
139    /// Check if currently navigating history
140    pub fn is_navigating(&self) -> bool {
141        self.current_index.is_some()
142    }
143
144    /// Get saved draft (text before navigation started)
145    pub fn saved_draft(&self) -> Option<&str> {
146        self.saved_draft.as_deref()
147    }
148
149    /// Reset navigation state
150    pub fn reset_navigation(&mut self) {
151        self.current_index = None;
152        self.saved_draft = None;
153    }
154
155    /// Get number of entries
156    pub fn len(&self) -> usize {
157        self.entries.len()
158    }
159
160    /// Check if history is empty
161    pub fn is_empty(&self) -> bool {
162        self.entries.is_empty()
163    }
164
165    /// Get all entries (for debugging)
166    pub fn entries(&self) -> &[String] {
167        &self.entries
168    }
169
170    /// Load history from file
171    pub fn load(path: &PathBuf) -> Result<Self, String> {
172        if !path.exists() {
173            return Ok(Self::new());
174        }
175
176        let data = fs::read(path).map_err(|e| format!("Failed to read history file: {}", e))?;
177
178        let entries: Vec<String> =
179            deserialize(&data).map_err(|e| format!("Failed to deserialize history: {}", e))?;
180
181        Ok(Self {
182            entries,
183            max_size: MAX_HISTORY_SIZE,
184            current_index: None,
185            saved_draft: None,
186        })
187    }
188
189    /// Save history to file
190    pub fn save(&self, path: &PathBuf) -> Result<(), String> {
191        // Create parent directory if needed
192        if let Some(parent) = path.parent() {
193            fs::create_dir_all(parent)
194                .map_err(|e| format!("Failed to create history directory: {}", e))?;
195        }
196
197        let serialized =
198            serialize(&self.entries).map_err(|e| format!("Failed to serialize history: {}", e))?;
199
200        fs::write(path, serialized).map_err(|e| format!("Failed to write history file: {}", e))?;
201
202        Ok(())
203    }
204
205    /// Clear all history
206    pub fn clear(&mut self) {
207        self.entries.clear();
208        self.current_index = None;
209        self.saved_draft = None;
210    }
211}
212
213impl Default for InputHistory {
214    fn default() -> Self {
215        Self::new()
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use tempfile::tempdir;
223
224    #[test]
225    fn test_new_history_is_empty() {
226        let history = InputHistory::new();
227        assert!(history.is_empty());
228        assert_eq!(history.len(), 0);
229        assert!(!history.is_navigating());
230    }
231
232    #[test]
233    fn test_add_entry() {
234        let mut history = InputHistory::new();
235        history.add("hello");
236
237        assert_eq!(history.len(), 1);
238        assert_eq!(history.entries()[0], "hello");
239    }
240
241    #[test]
242    fn test_add_multiple_entries() {
243        let mut history = InputHistory::new();
244        history.add("first");
245        history.add("second");
246        history.add("third");
247
248        assert_eq!(history.len(), 3);
249        // Most recent first
250        assert_eq!(history.entries()[0], "third");
251        assert_eq!(history.entries()[1], "second");
252        assert_eq!(history.entries()[2], "first");
253    }
254
255    #[test]
256    fn test_add_empty_ignored() {
257        let mut history = InputHistory::new();
258        history.add("");
259        history.add("   ");
260
261        assert!(history.is_empty());
262    }
263
264    #[test]
265    fn test_add_duplicate_most_recent_ignored() {
266        let mut history = InputHistory::new();
267        history.add("hello");
268        history.add("hello");
269
270        assert_eq!(history.len(), 1);
271    }
272
273    #[test]
274    fn test_add_duplicate_older_allowed() {
275        let mut history = InputHistory::new();
276        history.add("hello");
277        history.add("world");
278        history.add("hello"); // Re-add hello - it moves to front
279
280        assert_eq!(history.len(), 3);
281        assert_eq!(history.entries()[0], "hello"); // most recent (re-added)
282        assert_eq!(history.entries()[1], "world"); // middle
283        assert_eq!(history.entries()[2], "hello"); // oldest (original)
284    }
285
286    #[test]
287    fn test_max_size_truncation() {
288        let mut history = InputHistory::with_max_size(3);
289        history.add("first");
290        history.add("second");
291        history.add("third");
292        history.add("fourth");
293
294        assert_eq!(history.len(), 3);
295        assert_eq!(history.entries()[0], "fourth");
296        assert_eq!(history.entries()[2], "second");
297    }
298
299    #[test]
300    fn test_navigate_up_empty_history() {
301        let mut history = InputHistory::new();
302        let result = history.navigate_up("draft");
303
304        assert!(result.is_none());
305        assert!(!history.is_navigating());
306    }
307
308    #[test]
309    fn test_navigate_up_saves_draft() {
310        let mut history = InputHistory::new();
311        history.add("entry");
312
313        history.navigate_up("my draft");
314
315        assert!(history.is_navigating());
316        assert_eq!(history.saved_draft(), Some("my draft"));
317    }
318
319    #[test]
320    fn test_navigate_up_empty_draft_not_saved() {
321        let mut history = InputHistory::new();
322        history.add("entry");
323
324        history.navigate_up("");
325
326        assert!(history.is_navigating());
327        assert!(history.saved_draft().is_none());
328    }
329
330    #[test]
331    fn test_navigate_up_multiple() {
332        let mut history = InputHistory::new();
333        history.add("oldest");
334        history.add("middle");
335        history.add("newest");
336
337        let first = history.navigate_up("");
338        assert_eq!(first, Some("newest"));
339        assert_eq!(history.current(), Some("newest"));
340
341        let second = history.navigate_up("");
342        assert_eq!(second, Some("middle"));
343
344        let third = history.navigate_up("");
345        assert_eq!(third, Some("oldest"));
346
347        // At oldest, another up stays at oldest
348        let fourth = history.navigate_up("");
349        assert_eq!(fourth, Some("oldest"));
350    }
351
352    #[test]
353    fn test_navigate_down_from_oldest() {
354        let mut history = InputHistory::new();
355        history.add("oldest");
356        history.add("newest");
357
358        // Navigate to oldest
359        history.navigate_up("");
360        history.navigate_up("");
361        assert_eq!(history.current(), Some("oldest"));
362
363        // Navigate down to newest
364        let result = history.navigate_down();
365        assert_eq!(result, Some("newest"));
366
367        // Navigate down again - should signal restore draft
368        let result = history.navigate_down();
369        assert!(result.is_none());
370        assert!(!history.is_navigating());
371    }
372
373    #[test]
374    fn test_navigate_down_without_navigation() {
375        let mut history = InputHistory::new();
376        history.add("entry");
377
378        // Navigate down without up - should do nothing
379        let result = history.navigate_down();
380        assert!(result.is_none());
381        assert!(!history.is_navigating());
382    }
383
384    #[test]
385    fn test_reset_navigation() {
386        let mut history = InputHistory::new();
387        history.add("entry");
388
389        history.navigate_up("draft");
390        assert!(history.is_navigating());
391
392        history.reset_navigation();
393        assert!(!history.is_navigating());
394        assert!(history.saved_draft().is_none());
395    }
396
397    #[test]
398    fn test_add_resets_navigation() {
399        let mut history = InputHistory::new();
400        history.add("first");
401
402        history.navigate_up("draft");
403        assert!(history.is_navigating());
404
405        history.add("second");
406        assert!(!history.is_navigating());
407        assert!(history.saved_draft().is_none());
408    }
409
410    #[test]
411    fn test_roundtrip_save_load() {
412        let dir = tempdir().unwrap();
413        let path = dir.path().join("history.bin");
414
415        let mut history = InputHistory::new();
416        history.add("first");
417        history.add("second");
418        history.add("third");
419
420        history.save(&path).unwrap();
421
422        let loaded = InputHistory::load(&path).unwrap();
423        assert_eq!(loaded.len(), 3);
424        assert_eq!(loaded.entries()[0], "third");
425        assert_eq!(loaded.entries()[1], "second");
426        assert_eq!(loaded.entries()[2], "first");
427    }
428
429    #[test]
430    fn test_load_missing_file_returns_empty() {
431        let dir = tempdir().unwrap();
432        let path = dir.path().join("nonexistent.bin");
433
434        let history = InputHistory::load(&path).unwrap();
435        assert!(history.is_empty());
436    }
437
438    #[test]
439    fn test_save_creates_parent_directory() {
440        let dir = tempdir().unwrap();
441        let path = dir.path().join("nested").join("dir").join("history.bin");
442
443        let mut history = InputHistory::new();
444        history.add("test");
445
446        history.save(&path).unwrap();
447        assert!(path.exists());
448    }
449
450    #[test]
451    fn test_clear() {
452        let mut history = InputHistory::new();
453        history.add("entry");
454        history.navigate_up("draft");
455
456        history.clear();
457
458        assert!(history.is_empty());
459        assert!(!history.is_navigating());
460        assert!(history.saved_draft().is_none());
461    }
462
463    #[test]
464    fn test_trim_on_add() {
465        let mut history = InputHistory::new();
466        history.add("  hello world  ");
467
468        assert_eq!(history.entries()[0], "hello world");
469    }
470
471    #[test]
472    fn test_navigate_up_down_cycle() {
473        let mut history = InputHistory::new();
474        history.add("oldest");
475        history.add("middle");
476        history.add("newest");
477
478        // Save draft
479        history.navigate_up("my draft");
480
481        // Go to oldest
482        history.navigate_up("");
483        history.navigate_up("");
484        assert_eq!(history.current(), Some("oldest"));
485
486        // Come back to newest
487        history.navigate_down();
488        assert_eq!(history.current(), Some("middle"));
489
490        history.navigate_down();
491        assert_eq!(history.current(), Some("newest"));
492
493        // Final down should return None (signal to restore draft)
494        let result = history.navigate_down();
495        assert!(result.is_none());
496        assert_eq!(history.saved_draft(), Some("my draft"));
497    }
498}