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}