Skip to main content

limit_cli/tui/input/
history.rs

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