starweaver-cli 0.10.0

Command-line interface for Starweaver
Documentation
use std::{
    fs,
    io::Write as _,
    path::{Path, PathBuf},
};

use ring::digest::{SHA256, digest};
use serde::{Deserialize, Serialize};
use unicode_segmentation::UnicodeSegmentation;

use super::InteractiveTuiState;

const HISTORY_VERSION: u32 = 1;
const HISTORY_MAX_ENTRIES: usize = 100;
pub(super) const HISTORY_MAX_ENTRY_BYTES: usize = 16 * 1024;
const HISTORY_MAX_FILE_BYTES: usize = 256 * 1024;

#[derive(Clone, Debug, Deserialize, Serialize)]
struct PromptHistoryFile {
    version: u32,
    workspace: String,
    entries: Vec<String>,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub(in crate::tui) struct HistorySearchState {
    pub(in crate::tui) query: String,
    matches: Vec<usize>,
    selected: usize,
}

impl HistorySearchState {
    fn new(query: String, history: &[String]) -> Self {
        let mut state = Self {
            query,
            matches: Vec::new(),
            selected: 0,
        };
        state.refresh(history);
        state
    }

    fn refresh(&mut self, history: &[String]) {
        let query = self.query.to_lowercase();
        self.matches = history
            .iter()
            .enumerate()
            .rev()
            .filter_map(|(index, entry)| {
                (query.is_empty() || entry.to_lowercase().contains(&query)).then_some(index)
            })
            .collect();
        self.selected = 0;
    }

    const fn move_selection(&mut self, delta: isize) {
        if self.matches.is_empty() {
            return;
        }
        let len = self.matches.len();
        let steps = delta.unsigned_abs() % len;
        self.selected = if delta.is_negative() {
            (self.selected + len - steps) % len
        } else {
            (self.selected + steps) % len
        };
    }

    fn selected_history_index(&self) -> Option<usize> {
        self.matches.get(self.selected).copied()
    }

    pub(in crate::tui) fn position(&self) -> Option<(usize, usize)> {
        (!self.matches.is_empty()).then_some((self.selected + 1, self.matches.len()))
    }
}

pub(super) fn load_prompt_history(config_dir: &Path, workspace: &str) -> (PathBuf, Vec<String>) {
    let path = prompt_history_path(config_dir, workspace);
    let entries = load_history_file(&path, workspace).unwrap_or_default();
    (path, entries)
}

fn prompt_history_path(config_dir: &Path, workspace: &str) -> PathBuf {
    let hash = digest(&SHA256, workspace.as_bytes());
    let key = hash.as_ref()[..16]
        .iter()
        .fold(String::with_capacity(32), |mut key, byte| {
            const HEX: &[u8; 16] = b"0123456789abcdef";
            key.push(char::from(HEX[usize::from(*byte >> 4)]));
            key.push(char::from(HEX[usize::from(*byte & 0x0f)]));
            key
        });
    config_dir
        .join("prompt-history")
        .join(format!("{key}.json"))
}

fn load_history_file(path: &Path, workspace: &str) -> Option<Vec<String>> {
    let metadata = fs::metadata(path).ok()?;
    if metadata.len() > HISTORY_MAX_FILE_BYTES as u64 {
        return None;
    }
    let content = fs::read(path).ok()?;
    let stored = serde_json::from_slice::<PromptHistoryFile>(&content).ok()?;
    if stored.version != HISTORY_VERSION || stored.workspace != workspace {
        return None;
    }
    Some(bound_entries(stored.entries))
}

pub(super) fn bound_entries(entries: Vec<String>) -> Vec<String> {
    let mut total = 0usize;
    let mut retained = Vec::new();
    for entry in entries.into_iter().rev() {
        let entry = entry.trim().to_string();
        let bytes = entry.len();
        if entry.is_empty() || bytes > HISTORY_MAX_ENTRY_BYTES {
            continue;
        }
        if total.saturating_add(bytes) > HISTORY_MAX_FILE_BYTES {
            break;
        }
        total = total.saturating_add(bytes);
        retained.push(entry);
        if retained.len() >= HISTORY_MAX_ENTRIES {
            break;
        }
    }
    retained.reverse();
    retained
}

fn persist_prompt_history(path: &Path, workspace: &str, entries: &[String]) -> Result<(), String> {
    let Some(dir) = path.parent() else {
        return Err("history path has no parent".to_string());
    };
    fs::create_dir_all(dir).map_err(|error| error.to_string())?;
    set_private_directory_permissions(dir).map_err(|error| error.to_string())?;
    let stored = PromptHistoryFile {
        version: HISTORY_VERSION,
        workspace: workspace.to_string(),
        entries: bound_entries(entries.to_vec()),
    };
    let data = serde_json::to_vec_pretty(&stored).map_err(|error| error.to_string())?;
    let temp = dir.join(format!("history.{}.json.tmp", std::process::id()));
    write_private_file(&temp, &data).map_err(|error| error.to_string())?;
    fs::rename(&temp, path).map_err(|error| error.to_string())?;
    Ok(())
}

#[cfg(unix)]
fn set_private_directory_permissions(path: &Path) -> std::io::Result<()> {
    use std::os::unix::fs::PermissionsExt as _;
    fs::set_permissions(path, fs::Permissions::from_mode(0o700))
}

#[cfg(not(unix))]
fn set_private_directory_permissions(_path: &Path) -> std::io::Result<()> {
    Ok(())
}

fn write_private_file(path: &Path, data: &[u8]) -> std::io::Result<()> {
    let mut options = fs::OpenOptions::new();
    options.write(true).create(true).truncate(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt as _;
        options.mode(0o600);
    }
    let mut file = options.open(path)?;
    file.write_all(data)?;
    file.sync_all()
}

impl InteractiveTuiState {
    pub(in crate::tui) const fn history_search_visible(&self) -> bool {
        self.history_search.is_some()
    }

    pub(in crate::tui) const fn history_search(&self) -> Option<&HistorySearchState> {
        self.history_search.as_ref()
    }

    pub(in crate::tui) fn history_search_result(&self) -> Option<&str> {
        let index = self.history_search.as_ref()?.selected_history_index()?;
        self.history.get(index).map(String::as_str)
    }

    pub(in crate::tui) fn open_history_search(&mut self) {
        if self.history.is_empty() {
            self.input_status = Some("prompt history is empty".to_string());
            return;
        }
        self.close_command_palette();
        self.history_search = Some(HistorySearchState::new(self.input.clone(), &self.history));
        self.input_status = Some("history search".to_string());
    }

    pub(in crate::tui) fn close_history_search(&mut self) {
        self.history_search = None;
        self.input_status = Some("history search closed".to_string());
    }

    pub(in crate::tui) fn accept_history_search(&mut self) {
        let selected = self
            .history_search
            .as_ref()
            .and_then(HistorySearchState::selected_history_index)
            .and_then(|index| self.history.get(index))
            .cloned();
        self.history_search = None;
        if let Some(selected) = selected {
            self.input = selected;
            self.move_composer_cursor_to_end();
            self.reset_composer_scroll();
            self.refresh_command_palette();
            self.input_status = Some("history recalled".to_string());
        } else {
            self.input_status = Some("no matching history".to_string());
        }
    }

    pub(in crate::tui) const fn move_history_search(&mut self, delta: isize) {
        if let Some(search) = self.history_search.as_mut() {
            search.move_selection(delta);
        }
    }

    pub(in crate::tui) fn push_history_search_char(&mut self, ch: char) {
        if let Some(search) = self.history_search.as_mut() {
            search.query.push(ch);
            search.refresh(&self.history);
        }
    }

    pub(in crate::tui) fn backspace_history_search(&mut self) {
        if let Some(search) = self.history_search.as_mut()
            && let Some((index, _)) = search.query.grapheme_indices(true).next_back()
        {
            search.query.truncate(index);
            search.refresh(&self.history);
        }
    }

    pub(in crate::tui) const fn repeat_history_search(&mut self) {
        self.move_history_search(1);
    }

    pub(super) fn clear_persisted_history(&mut self) {
        self.history.clear();
        self.history_index = None;
        self.history_draft.clear();
        self.history_search = None;
        if !self.history_persistence_enabled {
            return;
        }
        if let Err(error) =
            persist_prompt_history(&self.history_path, &self.workspace_dir, &self.history)
        {
            self.input_status = Some(format!("history clear failed: {error}"));
        }
    }

    pub(super) fn persist_history(&mut self) {
        if !self.history_persistence_enabled {
            return;
        }
        if let Err(error) =
            persist_prompt_history(&self.history_path, &self.workspace_dir, &self.history)
        {
            self.input_status = Some(format!("history save failed: {error}"));
        }
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use tempfile::tempdir;

    use super::*;

    #[test]
    fn history_store_is_workspace_scoped_bounded_and_private() {
        let dir = tempdir().unwrap();
        let workspace = "/workspace/example";
        let (path, entries) = load_prompt_history(dir.path(), workspace);
        assert!(entries.is_empty());
        persist_prompt_history(&path, workspace, &["first".into(), "second".into()]).unwrap();
        assert_eq!(
            load_prompt_history(dir.path(), workspace).1,
            ["first", "second"]
        );
        assert!(
            load_prompt_history(dir.path(), "/workspace/other")
                .1
                .is_empty()
        );
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt as _;
            assert_eq!(
                fs::metadata(path).unwrap().permissions().mode() & 0o777,
                0o600
            );
        }
    }

    #[test]
    fn history_store_recovers_from_malformed_and_oversized_state() {
        let dir = tempdir().unwrap();
        let workspace = "/workspace/example";
        let (path, _) = load_prompt_history(dir.path(), workspace);
        fs::create_dir_all(path.parent().unwrap()).unwrap();
        fs::write(&path, b"not-json").unwrap();
        assert!(load_prompt_history(dir.path(), workspace).1.is_empty());

        let entries = (0..150)
            .map(|index| format!("prompt {index}"))
            .chain(std::iter::once("x".repeat(HISTORY_MAX_ENTRY_BYTES + 1)))
            .collect::<Vec<_>>();
        persist_prompt_history(&path, workspace, &entries).unwrap();
        let loaded = load_prompt_history(dir.path(), workspace).1;
        assert_eq!(loaded.len(), HISTORY_MAX_ENTRIES);
        assert_eq!(loaded.first().map(String::as_str), Some("prompt 50"));
        assert_eq!(loaded.last().map(String::as_str), Some("prompt 149"));
    }

    #[test]
    fn history_search_filters_newest_first() {
        let state = HistorySearchState::new(
            "fix".to_string(),
            &[
                "fix old".to_string(),
                "other".to_string(),
                "fix new".to_string(),
            ],
        );
        assert_eq!(state.matches, [2, 0]);
        assert_eq!(state.position(), Some((1, 2)));
    }
}