par-term-config 0.14.2

Configuration system for par-term terminal emulator
Documentation
//! Assistant input-history persistence.
//!
//! Stores submitted Assistant prompts as YAML in the par-term config directory.

use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};

use crate::Config;

const INPUT_HISTORY_FILE_NAME: &str = "assistant_input_history.yaml";
pub const MAX_ASSISTANT_INPUT_HISTORY_ENTRIES: usize = 200;

/// Internal YAML file structure for assistant input history persistence.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct AssistantInputHistoryFile {
    /// Ordered list of previously submitted prompt strings.
    #[serde(default)]
    entries: Vec<String>,
}

pub fn assistant_input_history_path() -> PathBuf {
    Config::config_dir().join(INPUT_HISTORY_FILE_NAME)
}

/// Load the persisted Assistant input history, newest entry first.
///
/// A missing or empty history file yields an empty list rather than an error.
///
/// # Errors
///
/// Returns an error if the history file exists but cannot be read (any I/O
/// failure other than "not found"), or if its contents are not valid YAML for
/// the history schema.
pub fn load_assistant_input_history() -> Result<Vec<String>, String> {
    load_assistant_input_history_from_path(&assistant_input_history_path())
}

/// Persist `entries` as the Assistant input history.
///
/// Entries are trimmed, de-duplicated, and truncated to
/// [`MAX_ASSISTANT_INPUT_HISTORY_ENTRIES`] before writing.
///
/// The file is replaced atomically at mode `0o600`.
///
/// # Errors
///
/// Returns an error if the config directory cannot be created, if the history
/// cannot be serialized to YAML, or if writing or renaming the file fails.
pub fn save_assistant_input_history(entries: &[String]) -> Result<(), String> {
    save_assistant_input_history_to_path(&assistant_input_history_path(), entries)
}

pub fn normalize_assistant_input_history(entries: impl IntoIterator<Item = String>) -> Vec<String> {
    let mut normalized = Vec::new();

    for entry in entries {
        let trimmed = entry.trim();
        if trimmed.is_empty() || normalized.iter().any(|existing| existing == trimmed) {
            continue;
        }

        normalized.push(trimmed.to_string());
        if normalized.len() >= MAX_ASSISTANT_INPUT_HISTORY_ENTRIES {
            break;
        }
    }

    normalized
}

pub fn merge_assistant_input_history(
    current_entries: &[String],
    persisted_entries: &[String],
) -> Vec<String> {
    normalize_assistant_input_history(
        current_entries
            .iter()
            .chain(persisted_entries.iter())
            .cloned(),
    )
}

fn load_assistant_input_history_from_path(path: &Path) -> Result<Vec<String>, String> {
    let contents = match fs::read_to_string(path) {
        Ok(contents) => contents,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
        Err(error) => {
            return Err(format!(
                "read assistant input history {}: {error}",
                path.display()
            ));
        }
    };

    if contents.trim().is_empty() {
        return Ok(Vec::new());
    }

    let file: AssistantInputHistoryFile = serde_yaml_ng::from_str(&contents)
        .map_err(|error| format!("parse assistant input history {}: {error}", path.display()))?;
    Ok(normalize_assistant_input_history(file.entries))
}

/// SEC-021: written through [`crate::atomic_save`] at mode `0o600`. Submitted
/// Assistant prompts are par-term's own state and frequently quote source code
/// and credentials, so they must not be readable by other users; and a
/// truncated file fails to parse, discarding the whole history.
fn save_assistant_input_history_to_path(path: &Path, entries: &[String]) -> Result<(), String> {
    let file = AssistantInputHistoryFile {
        entries: normalize_assistant_input_history(entries.iter().cloned()),
    };
    crate::atomic_save::save_yaml_atomic(path, &file).map_err(|error| {
        format!(
            "write assistant input history {}: {error:#}",
            path.display()
        )
    })
}

#[cfg(test)]
mod tests {
    use super::{
        load_assistant_input_history_from_path, normalize_assistant_input_history,
        save_assistant_input_history_to_path,
    };

    #[test]
    fn assistant_input_history_normalize_preserves_first_newest_entry() {
        let entries = normalize_assistant_input_history(vec![
            " newest ".to_string(),
            String::new(),
            "older".to_string(),
            "newest".to_string(),
        ]);

        assert_eq!(entries, vec!["newest".to_string(), "older".to_string()]);
    }

    #[test]
    fn assistant_input_history_load_missing_file_is_empty() {
        let temp_dir = tempfile::tempdir().expect("tempdir");
        let path = temp_dir.path().join("assistant_input_history.yaml");

        let entries = load_assistant_input_history_from_path(&path).expect("load history");

        assert!(entries.is_empty());
    }

    #[test]
    fn assistant_input_history_save_and_load_round_trips() {
        let temp_dir = tempfile::tempdir().expect("tempdir");
        let path = temp_dir.path().join("assistant_input_history.yaml");

        save_assistant_input_history_to_path(
            &path,
            &[
                " newest ".to_string(),
                String::new(),
                "older".to_string(),
                "newest".to_string(),
            ],
        )
        .expect("save history");

        let entries = load_assistant_input_history_from_path(&path).expect("load history");

        assert_eq!(entries, vec!["newest".to_string(), "older".to_string()]);
    }

    /// SEC-021: prompts frequently quote source code and credentials.
    #[cfg(unix)]
    #[test]
    fn assistant_input_history_is_owner_only() {
        use std::os::unix::fs::PermissionsExt;

        let temp_dir = tempfile::tempdir().expect("tempdir");
        let path = temp_dir.path().join("assistant_input_history.yaml");
        std::fs::write(&path, "entries: []").expect("seed");
        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).expect("chmod");

        save_assistant_input_history_to_path(&path, &["deploy the prod key".to_string()])
            .expect("save history");

        let mode = std::fs::metadata(&path)
            .expect("metadata")
            .permissions()
            .mode()
            & 0o777;
        assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
    }

    #[test]
    fn assistant_input_history_merge_keeps_current_entries_before_persisted_entries() {
        let current = vec!["window prompt".to_string(), "shared".to_string()];
        let persisted = vec!["other window".to_string(), "shared".to_string()];

        let entries = super::merge_assistant_input_history(&current, &persisted);

        assert_eq!(
            entries,
            vec![
                "window prompt".to_string(),
                "shared".to_string(),
                "other window".to_string(),
            ]
        );
    }
}