Skip to main content

fresh/input/
input_history.rs

1//! Input history for prompt navigation
2//!
3//! This module provides a history mechanism for prompts, similar to bash/readline.
4//! Users can navigate through previously entered values using up/down arrow keys.
5//!
6//! ## Design Goals
7//!
8//! 1. **Intuitive navigation**: Behaves like bash/readline history
9//!    - Up arrow moves to previous (older) items
10//!    - Down arrow moves to next (newer) items
11//!    - Pressing down past the last item returns to current input
12//!
13//! 2. **Non-destructive editing**: Editing historical items doesn't modify stored history
14//!    - History items are immutable once stored
15//!    - Edits only affect the current prompt input
16//!
17//! 3. **Persistence-ready**: Designed for future file-based persistence
18//!    - Simple structure that can be serialized (Vec<String>)
19//!    - Placeholder methods for save/load operations
20//!    - Separate histories for different prompt types (search vs replace)
21//!
22//! ## Usage Example
23//!
24//! ```
25//! use fresh::input_history::InputHistory;
26//!
27//! let mut history = InputHistory::new();
28//!
29//! // Add items to history
30//! history.push("first search".to_string());
31//! history.push("second search".to_string());
32//!
33//! // Navigate backwards (up arrow)
34//! let prev = history.navigate_prev("current input");
35//! assert_eq!(prev, Some("second search".to_string()));
36//!
37//! // Navigate backwards again
38//! let prev2 = history.navigate_prev("current input");
39//! assert_eq!(prev2, Some("first search".to_string()));
40//!
41//! // Navigate forwards (down arrow)
42//! let next = history.navigate_next();
43//! assert_eq!(next, Some("second search".to_string()));
44//!
45//! // Navigate past the end returns to original input
46//! let next2 = history.navigate_next();
47//! assert_eq!(next2, Some("current input".to_string()));
48//! ```
49
50/// Input history for prompt navigation (like bash/readline)
51///
52/// This struct maintains a history of previously entered values
53/// and allows navigating through them with up/down arrows.
54///
55/// ## Navigation Behavior
56///
57/// - History items are stored in a Vec (oldest to newest)
58/// - `position = None` means "at current input" (not navigating)
59/// - `position = Some(i)` means "viewing history item i"
60/// - When you first press up, current input is saved to `temp_input`
61/// - When you navigate past the end (down from last item), `temp_input` is restored
62///
63/// ## Future Persistence
64///
65/// To add persistence later:
66/// - Implement `serde::Serialize` and `serde::Deserialize`
67/// - Add methods: `save_to_file()`, `load_from_file()`
68/// - Store in config directory, separate files per history type
69#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
70pub struct InputHistory {
71    /// History items (oldest to newest)
72    items: Vec<String>,
73    /// Maximum number of items to keep
74    max_size: usize,
75    /// Current navigation position
76    /// - None = at current input (not navigating)
77    /// - Some(index) = viewing history item at index
78    position: Option<usize>,
79    /// Temporary storage for current input when navigating away
80    temp_input: Option<String>,
81}
82
83impl InputHistory {
84    /// Default maximum history size
85    pub const DEFAULT_MAX_SIZE: usize = 100;
86
87    /// Create a new history with default capacity (100 items)
88    pub fn new() -> Self {
89        Self::with_capacity(Self::DEFAULT_MAX_SIZE)
90    }
91
92    /// Create a new history with specified capacity
93    ///
94    /// # Arguments
95    /// * `max_size` - Maximum number of history items to keep (must be > 0)
96    ///
97    /// # Panics
98    /// Panics if `max_size` is 0
99    pub fn with_capacity(max_size: usize) -> Self {
100        assert!(max_size > 0, "History max_size must be greater than 0");
101        Self {
102            items: Vec::new(),
103            max_size,
104            position: None,
105            temp_input: None,
106        }
107    }
108
109    /// Add an item to history (most recent)
110    ///
111    /// This method:
112    /// - Skips empty strings
113    /// - Skips exact duplicates of the most recent item
114    /// - Enforces max_size by removing oldest items
115    /// - Resets navigation state
116    ///
117    /// # Example
118    /// ```
119    /// # use fresh::input_history::InputHistory;
120    /// let mut history = InputHistory::new();
121    /// history.push("first".to_string());
122    /// history.push("second".to_string());
123    /// history.push("second".to_string()); // Skipped (duplicate)
124    /// assert_eq!(history.len(), 2);
125    /// ```
126    pub fn push(&mut self, item: String) {
127        // Skip empty strings
128        if item.is_empty() {
129            return;
130        }
131
132        // Skip duplicates of the most recent item
133        if self.items.last().map(|s| s.as_str()) == Some(item.as_str()) {
134            return;
135        }
136
137        // Add the item
138        self.items.push(item);
139
140        // Enforce max size by removing oldest items
141        while self.items.len() > self.max_size {
142            self.items.remove(0);
143        }
144
145        // Reset navigation state
146        self.reset_navigation();
147    }
148
149    /// Navigate to previous item in history (up arrow)
150    ///
151    /// On first call, saves `current_input` to temporary storage and returns
152    /// the most recent history item. On subsequent calls, moves backwards
153    /// through history.
154    ///
155    /// # Arguments
156    /// * `current_input` - The current prompt input (saved on first navigation)
157    ///
158    /// # Returns
159    /// * `Some(String)` - The previous history item
160    /// * `None` - No more items (already at oldest)
161    ///
162    /// # Example
163    /// ```
164    /// # use fresh::input_history::InputHistory;
165    /// let mut history = InputHistory::new();
166    /// history.push("first".to_string());
167    /// history.push("second".to_string());
168    ///
169    /// let prev = history.navigate_prev("typing...");
170    /// assert_eq!(prev, Some("second".to_string()));
171    ///
172    /// let prev2 = history.navigate_prev("typing...");
173    /// assert_eq!(prev2, Some("first".to_string()));
174    ///
175    /// let prev3 = history.navigate_prev("typing...");
176    /// assert_eq!(prev3, None); // Already at oldest
177    /// ```
178    pub fn navigate_prev(&mut self, current_input: &str) -> Option<String> {
179        if self.items.is_empty() {
180            return None;
181        }
182
183        match self.position {
184            None => {
185                // First navigation: save current input and go to last item
186                self.temp_input = Some(current_input.to_string());
187                self.position = Some(self.items.len() - 1);
188                Some(self.items[self.items.len() - 1].clone())
189            }
190            Some(pos) if pos > 0 => {
191                // Navigate to previous item
192                self.position = Some(pos - 1);
193                Some(self.items[pos - 1].clone())
194            }
195            Some(_) => {
196                // Already at oldest item
197                None
198            }
199        }
200    }
201
202    /// Navigate to next item in history (down arrow)
203    ///
204    /// Moves forward through history (towards more recent items).
205    /// When navigating past the most recent item, returns the original
206    /// input that was saved when navigation started.
207    ///
208    /// # Returns
209    /// * `Some(String)` - The next history item, or original input if past end
210    /// * `None` - Not currently navigating
211    ///
212    /// # Example
213    /// ```
214    /// # use fresh::input_history::InputHistory;
215    /// let mut history = InputHistory::new();
216    /// history.push("first".to_string());
217    /// history.push("second".to_string());
218    ///
219    /// // Start navigating backwards
220    /// history.navigate_prev("typing...");
221    /// history.navigate_prev("typing...");
222    ///
223    /// // Navigate forwards
224    /// let next = history.navigate_next();
225    /// assert_eq!(next, Some("second".to_string()));
226    ///
227    /// // Navigate past the end returns to original input
228    /// let next2 = history.navigate_next();
229    /// assert_eq!(next2, Some("typing...".to_string()));
230    /// ```
231    pub fn navigate_next(&mut self) -> Option<String> {
232        match self.position {
233            None => {
234                // Not navigating
235                None
236            }
237            Some(pos) if pos < self.items.len() - 1 => {
238                // Navigate to next item
239                self.position = Some(pos + 1);
240                Some(self.items[pos + 1].clone())
241            }
242            Some(_) => {
243                // At most recent item, return to original input
244                let original = self.temp_input.clone();
245                self.reset_navigation();
246                original
247            }
248        }
249    }
250
251    /// Reset navigation state
252    ///
253    /// Call this when:
254    /// - User confirms the prompt (Enter)
255    /// - User cancels the prompt (Escape)
256    /// - User starts typing (optional, depends on desired behavior)
257    ///
258    /// This clears the temporary input storage and resets the position.
259    pub fn reset_navigation(&mut self) {
260        self.position = None;
261        self.temp_input = None;
262    }
263
264    /// Get the most recent item without navigating
265    ///
266    /// Useful for pre-filling prompts with the last search term.
267    ///
268    /// # Example
269    /// ```
270    /// # use fresh::input_history::InputHistory;
271    /// let mut history = InputHistory::new();
272    /// history.push("last search".to_string());
273    /// assert_eq!(history.last(), Some("last search"));
274    /// ```
275    pub fn last(&self) -> Option<&str> {
276        self.items.last().map(|s| s.as_str())
277    }
278
279    /// Initialize navigation at the last history item
280    ///
281    /// Call this when pre-filling a prompt with the last history item.
282    /// This sets up the navigation state so that pressing Up will go to
283    /// the second-to-last item, not the last item again.
284    ///
285    /// # Example
286    /// ```
287    /// # use fresh::input_history::InputHistory;
288    /// let mut history = InputHistory::new();
289    /// history.push("first".to_string());
290    /// history.push("second".to_string());
291    /// history.push("third".to_string());
292    ///
293    /// // Pre-fill prompt with "third"
294    /// history.init_at_last();
295    ///
296    /// // Now Up goes to "second", not "third"
297    /// let prev = history.navigate_prev("third");
298    /// assert_eq!(prev, Some("second".to_string()));
299    /// ```
300    pub fn init_at_last(&mut self) {
301        if !self.items.is_empty() {
302            self.position = Some(self.items.len() - 1);
303            self.temp_input = None;
304        }
305    }
306
307    /// Check if history is empty
308    pub fn is_empty(&self) -> bool {
309        self.items.is_empty()
310    }
311
312    /// Get number of items in history
313    pub fn len(&self) -> usize {
314        self.items.len()
315    }
316
317    /// Clear all history
318    ///
319    /// Removes all items and resets navigation state.
320    pub fn clear(&mut self) {
321        self.items.clear();
322        self.reset_navigation();
323    }
324
325    /// Get a reference to the history items (oldest to newest)
326    ///
327    /// Useful for session persistence.
328    pub fn items(&self) -> &[String] {
329        &self.items
330    }
331
332    /// Create a history from existing items
333    ///
334    /// Useful for session restoration.
335    pub fn from_items(items: Vec<String>) -> Self {
336        let mut history = Self::new();
337        // Add items respecting deduplication rules
338        for item in items {
339            history.push(item);
340        }
341        history
342    }
343
344    // ========================================================================
345    // Persistence methods
346    // ========================================================================
347
348    /// Save history to a file
349    pub fn save_to_file(&self, path: &std::path::Path) -> std::io::Result<()> {
350        // Only save items, not navigation state
351        let json = serde_json::to_string_pretty(&self.items).map_err(std::io::Error::other)?;
352
353        // Create parent directory if it doesn't exist
354        if let Some(parent) = path.parent() {
355            std::fs::create_dir_all(parent)?;
356        }
357
358        std::fs::write(path, json)?;
359        Ok(())
360    }
361
362    /// Load history from a file
363    pub fn load_from_file(path: &std::path::Path) -> std::io::Result<Self> {
364        if !path.exists() {
365            return Ok(Self::new());
366        }
367
368        let json = std::fs::read_to_string(path)?;
369        let items: Vec<String> = serde_json::from_str(&json).map_err(std::io::Error::other)?;
370
371        let mut history = Self::new();
372        history.items = items;
373
374        // Trim to max_size if file had more items
375        if history.items.len() > history.max_size {
376            let excess = history.items.len() - history.max_size;
377            history.items.drain(0..excess);
378        }
379
380        Ok(history)
381    }
382}
383
384/// Get the data directory for Fresh editor state
385/// Returns $XDG_DATA_HOME/fresh or ~/.local/share/fresh on Linux
386/// Returns ~/Library/Application Support/fresh on macOS
387pub fn get_data_dir() -> std::io::Result<std::path::PathBuf> {
388    let data_dir = dirs::data_dir().ok_or_else(|| {
389        std::io::Error::new(
390            std::io::ErrorKind::NotFound,
391            "Could not determine data directory",
392        )
393    })?;
394    Ok(data_dir.join("fresh"))
395}
396
397/// Get the path for search history file
398pub fn get_search_history_path() -> std::io::Result<std::path::PathBuf> {
399    Ok(get_data_dir()?.join("search_history.json"))
400}
401
402/// Get the path for replace history file
403pub fn get_replace_history_path() -> std::io::Result<std::path::PathBuf> {
404    Ok(get_data_dir()?.join("replace_history.json"))
405}
406
407impl Default for InputHistory {
408    fn default() -> Self {
409        Self::new()
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    #[test]
418    fn test_new_history_is_empty() {
419        let history = InputHistory::new();
420        assert!(history.is_empty());
421        assert_eq!(history.len(), 0);
422        assert_eq!(history.last(), None);
423    }
424
425    #[test]
426    fn test_push_adds_items() {
427        let mut history = InputHistory::new();
428        history.push("first".to_string());
429        history.push("second".to_string());
430        history.push("third".to_string());
431
432        assert_eq!(history.len(), 3);
433        assert_eq!(history.last(), Some("third"));
434    }
435
436    #[test]
437    fn test_push_skips_empty_strings() {
438        let mut history = InputHistory::new();
439        history.push("first".to_string());
440        history.push("".to_string());
441        history.push("second".to_string());
442
443        assert_eq!(history.len(), 2);
444    }
445
446    #[test]
447    fn test_push_skips_consecutive_duplicates() {
448        let mut history = InputHistory::new();
449        history.push("first".to_string());
450        history.push("second".to_string());
451        history.push("second".to_string());
452        history.push("second".to_string());
453        history.push("third".to_string());
454
455        assert_eq!(history.len(), 3);
456        assert_eq!(history.items, vec!["first", "second", "third"]);
457    }
458
459    #[test]
460    fn test_push_allows_non_consecutive_duplicates() {
461        let mut history = InputHistory::new();
462        history.push("search".to_string());
463        history.push("other".to_string());
464        history.push("search".to_string()); // Should be added
465
466        assert_eq!(history.len(), 3);
467        assert_eq!(history.items, vec!["search", "other", "search"]);
468    }
469
470    #[test]
471    fn test_navigate_prev_empty_history() {
472        let mut history = InputHistory::new();
473        let result = history.navigate_prev("current");
474        assert_eq!(result, None);
475    }
476
477    #[test]
478    fn test_navigate_prev_basic() {
479        let mut history = InputHistory::new();
480        history.push("first".to_string());
481        history.push("second".to_string());
482        history.push("third".to_string());
483
484        // First up: go to most recent
485        let prev = history.navigate_prev("typing...");
486        assert_eq!(prev, Some("third".to_string()));
487
488        // Second up: go to previous
489        let prev = history.navigate_prev("typing...");
490        assert_eq!(prev, Some("second".to_string()));
491
492        // Third up: go to oldest
493        let prev = history.navigate_prev("typing...");
494        assert_eq!(prev, Some("first".to_string()));
495
496        // Fourth up: no more items
497        let prev = history.navigate_prev("typing...");
498        assert_eq!(prev, None);
499    }
500
501    #[test]
502    fn test_navigate_next_without_prev() {
503        let mut history = InputHistory::new();
504        history.push("item".to_string());
505
506        // navigate_next without navigate_prev should return None
507        let result = history.navigate_next();
508        assert_eq!(result, None);
509    }
510
511    #[test]
512    fn test_navigate_next_returns_to_original() {
513        let mut history = InputHistory::new();
514        history.push("first".to_string());
515        history.push("second".to_string());
516
517        // Navigate backwards
518        history.navigate_prev("typing...");
519        history.navigate_prev("typing...");
520
521        // Navigate forwards
522        let next = history.navigate_next();
523        assert_eq!(next, Some("second".to_string()));
524
525        // Navigate past the end should return original input
526        let next = history.navigate_next();
527        assert_eq!(next, Some("typing...".to_string()));
528
529        // After returning to original, we're no longer navigating
530        let next = history.navigate_next();
531        assert_eq!(next, None);
532    }
533
534    #[test]
535    fn test_reset_navigation() {
536        let mut history = InputHistory::new();
537        history.push("item".to_string());
538
539        // Start navigating
540        history.navigate_prev("current");
541        assert!(history.position.is_some());
542        assert!(history.temp_input.is_some());
543
544        // Reset
545        history.reset_navigation();
546        assert!(history.position.is_none());
547        assert!(history.temp_input.is_none());
548    }
549
550    #[test]
551    fn test_max_size_enforcement() {
552        let mut history = InputHistory::with_capacity(3);
553
554        history.push("first".to_string());
555        history.push("second".to_string());
556        history.push("third".to_string());
557        assert_eq!(history.len(), 3);
558
559        // Adding fourth item should remove first
560        history.push("fourth".to_string());
561        assert_eq!(history.len(), 3);
562        assert_eq!(history.items, vec!["second", "third", "fourth"]);
563
564        // Adding fifth item should remove second
565        history.push("fifth".to_string());
566        assert_eq!(history.len(), 3);
567        assert_eq!(history.items, vec!["third", "fourth", "fifth"]);
568    }
569
570    #[test]
571    fn test_clear() {
572        let mut history = InputHistory::new();
573        history.push("first".to_string());
574        history.push("second".to_string());
575        history.navigate_prev("current");
576
577        history.clear();
578
579        assert!(history.is_empty());
580        assert_eq!(history.len(), 0);
581        assert!(history.position.is_none());
582        assert!(history.temp_input.is_none());
583    }
584
585    #[test]
586    fn test_up_down_up_down_sequence() {
587        let mut history = InputHistory::new();
588        history.push("first".to_string());
589        history.push("second".to_string());
590        history.push("third".to_string());
591
592        // Up, up, down, up sequence
593        assert_eq!(history.navigate_prev("current"), Some("third".to_string()));
594        assert_eq!(history.navigate_prev("current"), Some("second".to_string()));
595        assert_eq!(history.navigate_next(), Some("third".to_string()));
596        assert_eq!(history.navigate_prev("current"), Some("second".to_string()));
597    }
598
599    #[test]
600    fn test_full_navigation_cycle() {
601        let mut history = InputHistory::new();
602        history.push("alpha".to_string());
603        history.push("beta".to_string());
604        history.push("gamma".to_string());
605
606        let original = "my search query";
607
608        // Go all the way back
609        assert_eq!(history.navigate_prev(original), Some("gamma".to_string()));
610        assert_eq!(history.navigate_prev(original), Some("beta".to_string()));
611        assert_eq!(history.navigate_prev(original), Some("alpha".to_string()));
612        assert_eq!(history.navigate_prev(original), None); // At oldest
613
614        // Go all the way forward
615        assert_eq!(history.navigate_next(), Some("beta".to_string()));
616        assert_eq!(history.navigate_next(), Some("gamma".to_string()));
617        assert_eq!(history.navigate_next(), Some(original.to_string())); // Back to original
618        assert_eq!(history.navigate_next(), None); // Not navigating anymore
619    }
620
621    #[test]
622    #[should_panic(expected = "History max_size must be greater than 0")]
623    fn test_zero_capacity_panics() {
624        InputHistory::with_capacity(0);
625    }
626
627    #[test]
628    fn test_single_item_history() {
629        let mut history = InputHistory::with_capacity(1);
630
631        history.push("first".to_string());
632        history.push("second".to_string());
633        history.push("third".to_string());
634
635        // Should only keep the most recent item
636        assert_eq!(history.len(), 1);
637        assert_eq!(history.last(), Some("third"));
638    }
639}