modcli/shell/
history.rs

1//! Shell history utilities (persistence and search).
2//!
3//! Default path: ~/.config/modcli/history (Unix/macOS)
4//!
5//! These helpers are optional and can be used by applications embedding mod-cli.
6
7use crate::error::ModCliError;
8use std::fs::{self, File, OpenOptions};
9use std::io::{BufRead, BufReader, Write};
10use std::path::{Path, PathBuf};
11
12/// Resolve a default history path (best-effort, cross-platform fallback).
13pub fn default_history_path() -> PathBuf {
14    // Prefer XDG on Unix/macOS
15    if let Ok(home) = std::env::var("HOME") {
16        let base = Path::new(&home).join(".config").join("modcli");
17        return base.join("history");
18    }
19    // Windows USERPROFILE
20    if let Ok(home) = std::env::var("USERPROFILE") {
21        let base = Path::new(&home)
22            .join("AppData")
23            .join("Roaming")
24            .join("modcli");
25        return base.join("history");
26    }
27    // Fallback: current directory
28    PathBuf::from(".modcli_history")
29}
30
31/// Load history lines from a file, ignoring IO errors (returns empty on failure).
32pub fn load(path: Option<&Path>) -> Vec<String> {
33    let p: PathBuf = path
34        .map(|p| p.to_path_buf())
35        .unwrap_or_else(default_history_path);
36    let file = match File::open(&p) {
37        Ok(f) => f,
38        Err(_) => return Vec::new(),
39    };
40    let reader = BufReader::new(file);
41    reader.lines().map_while(Result::ok).collect()
42}
43
44/// Save all history lines to the target file, creating directories if needed.
45pub fn save(path: Option<&Path>, entries: &[String]) -> Result<(), ModCliError> {
46    let p: PathBuf = path
47        .map(|p| p.to_path_buf())
48        .unwrap_or_else(default_history_path);
49    if let Some(dir) = p.parent() {
50        fs::create_dir_all(dir)?;
51    }
52    let mut f = File::create(&p)?;
53    for e in entries {
54        writeln!(f, "{e}")?;
55    }
56    Ok(())
57}
58
59/// Append a single entry to history, creating files/dirs if necessary.
60pub fn add(path: Option<&Path>, line: &str) -> Result<(), ModCliError> {
61    let p: PathBuf = path
62        .map(|p| p.to_path_buf())
63        .unwrap_or_else(default_history_path);
64    if let Some(dir) = p.parent() {
65        fs::create_dir_all(dir)?;
66    }
67    let mut f = OpenOptions::new().create(true).append(true).open(&p)?;
68    writeln!(f, "{line}")?;
69    Ok(())
70}
71
72/// Simple case-insensitive substring search returning up to `limit` matches (most recent last).
73pub fn search(entries: &[String], query: &str, limit: usize) -> Vec<String> {
74    if query.is_empty() {
75        return entries
76            .iter()
77            .rev()
78            .take(limit)
79            .cloned()
80            .collect::<Vec<_>>()
81            .into_iter()
82            .rev()
83            .collect();
84    }
85    let q = query.to_ascii_lowercase();
86    let mut out: Vec<String> = Vec::new();
87    for e in entries.iter().rev() {
88        if e.to_ascii_lowercase().contains(&q) {
89            out.push(e.clone());
90            if out.len() >= limit {
91                break;
92            }
93        }
94    }
95    out.reverse();
96    out
97}