promkit-widgets 0.7.0

Widgets for promkit
Documentation
use std::{
    collections::VecDeque,
    fs::File,
    io::{BufRead, BufReader, Write},
    path::Path,
};

/// Manages the history of user inputs for a text editor.
/// This structure allows for the storage,
/// retrieval, and navigation through past inputs.
/// It supports adding new entries,
/// checking for the existence of specific entries,
/// and moving through the history in both forward and backward directions.
/// Additionally, it can limit the number of entries stored in the history
/// to a specified maximum size.
#[derive(Clone)]
pub struct History {
    /// Buffer storing the history of inputs as strings.
    entries: VecDeque<String>,
    position: usize,

    /// Optional limit on the number of entries in the history.
    /// If set, the history will not exceed this number of entries,
    /// and older entries will be removed to make room for new ones.
    pub limit_size: Option<usize>,
}

impl Default for History {
    /// Creates a new `History` instance with a single empty string in the buffer
    /// and initializes the position at 0.
    fn default() -> Self {
        Self {
            entries: VecDeque::from([String::new()]),
            position: 0,
            limit_size: None,
        }
    }
}

impl History {
    /// Saves the current history items to a file in reverse order (newest first),
    /// respecting the optional limit on the number of entries if provided.
    ///
    /// # Arguments
    ///
    /// * `path` - The path to the file where the history should be saved.
    ///
    /// # Returns
    ///
    /// Returns `Ok(())` if the history was successfully saved, or an `io::Error` otherwise.
    pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> anyhow::Result<()> {
        let mut file = File::create(path)?;
        let mut contents = self.entries.clone();

        // Note: The initial empty string ("") in the history
        // is not included in the save operation.
        contents.pop_back();

        let items_to_save: Vec<_> = if let Some(limit) = self.limit_size {
            contents.iter().rev().take(limit).collect()
        } else {
            contents.iter().rev().collect()
        };

        for item in items_to_save {
            writeln!(file, "{}", item)?;
        }
        Ok(())
    }

    /// Loads history items from a file into a new `History` instance.
    ///
    /// This function reads the specified file line by line, adding each non-empty line
    /// to the history. It respects the optional limit on the number of entries if provided.
    /// An empty string is always added at the end of the history to represent a new input line.
    /// After loading, the cursor is moved to the end of the history.
    ///
    /// # Arguments
    ///
    /// * `path` - The path to the file from which the history should be loaded.
    /// * `limit_size` - An optional limit on the number of entries in the history.
    ///
    /// # Returns
    ///
    /// Returns `Ok(History)` with the loaded history if successful, or an `io::Error` otherwise.
    pub fn load_from_file<P: AsRef<Path>>(
        path: P,
        limit_size: Option<usize>,
    ) -> anyhow::Result<Self> {
        let file = File::open(path)?;
        let reader = BufReader::new(file);

        let mut ret = Self {
            limit_size,
            ..Default::default()
        };

        for line in reader.lines() {
            let line = line?;
            if !line.is_empty() {
                // Avoid adding empty lines
                ret.entries.push_back(line);
            }
        }
        // Ensure there's always an empty string at the end of the buffer
        ret.entries.push_back(String::new());
        ret.move_to_tail(); // Move cursor to the end after loading
        Ok(ret)
    }

    /// Inserts a new item into the history.
    ///
    /// If the item does not already exist in the buffer,
    /// it is inserted just before the last item.
    /// This method ensures there is always an empty string
    /// at the end of the buffer to represent
    /// a new input line. After insertion,
    /// the current position is moved to the end of the buffer.
    ///
    /// # Arguments
    ///
    /// * `item` - The item to be inserted into the history.
    ///
    /// # Examples
    ///
    /// - Initial state: `items = [""]`
    /// - After inserting "abc": `items = ["abc", ""]`
    /// - After inserting "xyz": `items = ["abc", "xyz", ""]`
    pub fn insert<T: AsRef<str>>(&mut self, item: T) {
        let item = item.as_ref().to_string();
        if !self.exists(&item) {
            let init_state = self.entries.pop_back().unwrap();
            self.entries.push_back(item);
            if let Some(limit) = self.limit_size
                && limit < self.entries.len()
            {
                self.entries.pop_front();
            }
            self.entries.push_back(init_state);
        }
        self.move_to_tail();
    }

    /// Retrieves the current item from the history
    /// based on the current position.
    /// Returns an empty string if the position is out of bounds.
    pub fn get(&self) -> String {
        self.entries
            .get(self.position)
            .unwrap_or(&String::new())
            .to_string()
    }

    /// Checks whether a specific item exists in the history.
    ///
    /// # Arguments
    ///
    /// * `item` - The item to check for existence in the history.
    ///
    /// # Returns
    ///
    /// Returns `true` if the item exists in the history, `false` otherwise.
    fn exists<T: AsRef<str>>(&self, item: T) -> bool {
        self.entries.iter().any(|i| i == item.as_ref())
    }

    /// Moves the current position backward in the history, if possible.
    /// Returns `true` if the position was successfully moved backward, `false` otherwise.
    pub fn backward(&mut self) -> bool {
        let Some(position) = self.position.checked_sub(1) else {
            return false;
        };
        self.position = position;
        true
    }

    /// Moves the current position forward in the history, if possible.
    /// Returns `true` if the position was successfully moved forward, `false` otherwise.
    pub fn forward(&mut self) -> bool {
        if self.position.saturating_add(1) >= self.entries.len() {
            return false;
        }
        self.position += 1;
        true
    }

    /// Moves the current position to the tail (end) of the history buffer.
    pub fn move_to_tail(&mut self) {
        self.position = self.entries.len() - 1;
    }
}

#[cfg(test)]
mod tests {
    mod backward {
        use super::super::*;

        #[test]
        fn stops_at_the_oldest_entry() {
            let mut history = History::default();
            history.insert("first");
            history.insert("second");

            assert!(history.backward());
            assert_eq!(history.position, 1);
            assert_eq!(history.get(), "second");
            assert!(history.backward());
            assert_eq!(history.position, 0);
            assert_eq!(history.get(), "first");
            assert!(!history.backward());
            assert_eq!(history.position, 0);
        }
    }

    mod forward {
        use super::super::*;

        #[test]
        fn stops_at_the_editing_slot() {
            let mut history = History::default();
            history.insert("first");

            assert!(!history.forward());
            assert_eq!(history.position, 1);
            assert!(history.backward());
            assert!(history.forward());
            assert_eq!(history.position, 1);
        }
    }

    mod move_to_tail {
        use super::super::*;

        #[test]
        fn selects_the_editing_slot() {
            let mut history = History::default();
            history.insert("first");
            history.backward();

            history.move_to_tail();
            assert_eq!(history.position, 1);
        }
    }

    mod insert {
        use super::super::*;

        #[test]
        fn appends_an_entry_before_the_editing_slot() {
            let mut h = History::default();
            h.insert("item");
            assert_eq!(
                VecDeque::from([String::from("item"), String::new()]),
                h.entries
            );
        }

        #[test]
        fn preserves_insertion_order() {
            let mut h = History::default();
            h.insert("item1");
            h.insert("item2");
            assert_eq!(
                VecDeque::from([String::from("item1"), String::from("item2"), String::new()]),
                h.entries
            );
        }

        #[test]
        fn evicts_the_oldest_entry_at_the_size_limit() {
            let mut h = History {
                limit_size: Some(2),
                ..Default::default()
            };
            h.insert("item1");
            h.insert("item2");
            h.insert("item3");
            assert_eq!(
                VecDeque::from([String::from("item2"), String::from("item3"), String::new()]),
                h.entries
            );
        }
    }

    mod exists {
        use super::super::*;

        #[test]
        fn reports_whether_an_entry_exists() {
            let mut h = History::default();
            h.insert("existed");
            assert!(h.exists("existed"));
            assert!(!h.exists("not_found"));
        }
    }
}