supercode-harness 0.4.13

The optional native Supercode agent and tool harness
Documentation
//! P5-4 (§3.1, D5 "cross-session prompt history"; S6: homed here per the
//! design's own §2 module-30 row — "Ctrl+R-style search is a
//! composer/UX affordance"): a persisted, searchable list of prompts the
//! user has submitted, surviving across TUI sessions (unlike an in-memory
//! `Vec` that resets on exit). Deliberately its own small file format (one
//! prompt per line, blank lines and `\n` collapsed to a literal `\n` escape
//! so a multi-line prompt round-trips as ONE history entry — NOT the same
//! file `rustyline`'s REPL history uses, since rustyline's `DefaultHistory`
//! serialization is a private implementation detail of that crate, not a
//! format this crate should parse) so a TUI session started days later
//! still has yesterday's prompts to Ctrl+R through.

use std::path::Path;

/// A persisted, searchable prompt history.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PromptHistory {
    /// Oldest first.
    entries: Vec<String>,
}

impl PromptHistory {
    /// An empty history (no file backing yet).
    pub fn new() -> Self {
        PromptHistory::default()
    }

    /// Load from `path`'s one-escaped-prompt-per-line format. A missing
    /// file (first run) or an unreadable one is treated as "empty history"
    /// rather than an error — losing prompt history is never worth
    /// refusing to start the TUI over.
    pub fn load_from_file(path: &Path) -> Self {
        let Ok(text) = std::fs::read_to_string(path) else {
            return PromptHistory::new();
        };
        let entries = text
            .lines()
            .filter(|l| !l.is_empty())
            .map(unescape_entry)
            .collect();
        PromptHistory { entries }
    }

    /// Persist to `path` (one escaped prompt per line, overwriting).
    /// Best-effort: a write failure (e.g. a read-only config dir) is
    /// silently dropped — the in-memory session history is unaffected
    /// either way, matching [`crate::permissions::ApprovalCache::approve`]'s
    /// "a lost write costs a feature, never a crash" precedent.
    pub fn save_to_file(&self, path: &Path) {
        if let Some(parent) = path.parent() {
            let _ = std::fs::create_dir_all(parent);
        }
        let text: String = self
            .entries
            .iter()
            .map(|e| escape_entry(e))
            .collect::<Vec<_>>()
            .join("\n");
        let _ = std::fs::write(path, text);
    }

    /// Append one submitted prompt. Adjacent-duplicate suppression (the
    /// same prompt submitted twice in a row doesn't grow the list) mirrors
    /// shell/readline history convention.
    pub fn push(&mut self, entry: impl Into<String>) {
        let entry = entry.into();
        if entry.is_empty() {
            return;
        }
        if self.entries.last().map(String::as_str) != Some(entry.as_str()) {
            self.entries.push(entry);
        }
    }

    /// How many entries this history holds.
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    /// Whether this history is empty.
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    /// All entries the substring `query` (case-insensitive) appears in,
    /// MOST RECENT first (Ctrl+R convention: typing narrows toward the
    /// latest matching prompt). Empty `query` returns everything, most
    /// recent first.
    pub fn search(&self, query: &str) -> Vec<&str> {
        let q = query.to_ascii_lowercase();
        self.entries
            .iter()
            .rev()
            .filter(|e| q.is_empty() || e.to_ascii_lowercase().contains(&q))
            .map(String::as_str)
            .collect()
    }
}

fn escape_entry(s: &str) -> String {
    s.replace('\\', "\\\\").replace('\n', "\\n")
}

fn unescape_entry(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut chars = s.chars();
    while let Some(c) = chars.next() {
        if c == '\\' {
            match chars.next() {
                Some('n') => out.push('\n'),
                Some('\\') => out.push('\\'),
                Some(other) => {
                    out.push('\\');
                    out.push(other);
                }
                None => out.push('\\'),
            }
        } else {
            out.push(c);
        }
    }
    out
}

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

    #[test]
    fn push_then_search_finds_substring_most_recent_first() {
        let mut h = PromptHistory::new();
        h.push("fix the login bug");
        h.push("add a test for login");
        h.push("refactor the parser");
        let hits = h.search("login");
        assert_eq!(hits, vec!["add a test for login", "fix the login bug"]);
    }

    #[test]
    fn search_empty_query_returns_all_most_recent_first() {
        let mut h = PromptHistory::new();
        h.push("one");
        h.push("two");
        assert_eq!(h.search(""), vec!["two", "one"]);
    }

    #[test]
    fn adjacent_duplicate_is_not_appended_twice() {
        let mut h = PromptHistory::new();
        h.push("same");
        h.push("same");
        assert_eq!(h.len(), 1);
    }

    #[test]
    fn empty_push_is_ignored() {
        let mut h = PromptHistory::new();
        h.push("");
        assert!(h.is_empty());
    }

    #[test]
    fn round_trips_through_a_file_including_multiline_entries() {
        let dir = std::env::temp_dir().join(format!(
            "supercode-tui-history-test-{}-{}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        ));
        let path = dir.join("history.txt");
        let mut h = PromptHistory::new();
        h.push("single line");
        h.push("multi\nline\nprompt");
        h.push("with a \\ backslash");
        h.save_to_file(&path);
        let loaded = PromptHistory::load_from_file(&path);
        assert_eq!(loaded, h);
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn load_from_missing_file_is_empty_not_an_error() {
        let path = std::env::temp_dir().join("supercode-tui-history-definitely-missing.txt");
        let _ = std::fs::remove_file(&path);
        let h = PromptHistory::load_from_file(&path);
        assert!(h.is_empty());
    }
}